mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 10:41:28 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021e1165a7 | ||
|
|
96697ffe17 | ||
|
|
76245c13d4 | ||
|
|
53412f69b4 |
No files matched your search
@@ -1,2 +0,0 @@
|
||||
custom:
|
||||
- https://github.com/l0ng-ai/openidea#sponsor
|
||||
@@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
@@ -103,7 +103,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release-build.yml):
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
|
||||
@@ -47,7 +47,6 @@ env:
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -55,9 +54,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
|
||||
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
|
||||
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,12 +74,6 @@ jobs:
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
|
||||
- if: matrix.language == 'c' || matrix.language == 'cpp'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gdb
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
@@ -116,13 +108,6 @@ jobs:
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
|
||||
- if: matrix.language == 'php'
|
||||
uses: shivammathur/setup-php@v2
|
||||
with: { php-version: "8.5", coverage: xdebug, tools: composer }
|
||||
- if: matrix.language == 'php'
|
||||
run: npm install --global "intelephense@$INTELEPHENSE"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
name: Nightly
|
||||
|
||||
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
|
||||
# version, so remote development finds the server of that version. The `nightly` release holds
|
||||
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
|
||||
# nothing newer stops there. Only the latest builds are kept.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2,14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Build even if nothing is newer than the last nightly"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
if: github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- id: commit
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
|
||||
--json headSha --jq '.[0].headSha // ""')"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "::notice::no main commit has passed CI"
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { ref: "${{ steps.commit.outputs.sha }}" }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { node-version: 22 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.commit.outputs.sha != ''
|
||||
- id: release
|
||||
if: steps.commit.outputs.sha != ''
|
||||
env:
|
||||
SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
|
||||
tag="v$version"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "chore: $tag"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
|
||||
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag != ''
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
release_id: ${{ needs.prepare.outputs.release_id }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [prepare, binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
SHA: ${{ needs.prepare.outputs.sha }}
|
||||
KEEP: 14
|
||||
steps:
|
||||
- name: Publish the build
|
||||
run: gh release edit "$TAG" --tag "$TAG" --draft=false
|
||||
- name: Point the updater at it
|
||||
run: |
|
||||
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
|
||||
gh release view nightly >/dev/null 2>&1 ||
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
|
||||
--notes "The update manifest of the nightly builds, and the commit last built."
|
||||
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
|
||||
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
|
||||
# Failed builds' drafts go too, once they are this old.
|
||||
- name: Delete old builds
|
||||
run: |
|
||||
gh release list --limit 200 --json tagName,createdAt --jq \
|
||||
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
|
||||
while read -r tag; do
|
||||
gh release delete "$tag" --cleanup-tag --yes
|
||||
done
|
||||
@@ -22,8 +22,6 @@ env:
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
|
||||
@@ -1,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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
+186
-385
File diff suppressed because it is too large.
Load diff
+5
-17
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.42"
|
||||
version = "0.1.38"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -42,16 +42,9 @@ ide-fonts = { path = "crates/ide-fonts" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-mcp = { path = "crates/ide-mcp" }
|
||||
ide-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 +80,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 +101,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 +129,7 @@ debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
|
||||
# (Linux arm64 was killed mid-link from v0.1.39 on).
|
||||
lto = "thin"
|
||||
lto = true
|
||||
opt-level = 3
|
||||
# Unwind, so a panic fails one task or command instead of quitting the IDE.
|
||||
panic = "unwind"
|
||||
|
||||
@@ -12,15 +12,11 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh">Docs</a>
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
·
|
||||
<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,23 +64,20 @@ 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
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Database tool window notes, including Oracle Instant Client setup, are in
|
||||
[`docs/databases.md`](docs/databases.md).
|
||||
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
-26
@@ -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
@@ -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
|
||||
|
||||
@@ -9,7 +9,6 @@ license.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
@@ -22,5 +21,7 @@ tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,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)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -28,29 +27,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
pub(crate) const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
|
||||
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
|
||||
if code == AUTH_REQUIRED {
|
||||
return true;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| {
|
||||
*w == "auth"
|
||||
|| w.starts_with("authenticat")
|
||||
|| w.starts_with("authoriz")
|
||||
|| w.starts_with("unauthenticat")
|
||||
|| w.starts_with("unauthoriz")
|
||||
|| w.starts_with("oauth")
|
||||
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|
||||
|| words
|
||||
.windows(2)
|
||||
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
|
||||
}
|
||||
|
||||
/// The capabilities this client offers, in v1 form.
|
||||
fn client_capabilities() -> Value {
|
||||
json!({
|
||||
@@ -58,9 +34,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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,11 +69,6 @@ pub(crate) struct Negotiated {
|
||||
}
|
||||
|
||||
impl Negotiated {
|
||||
pub fn mcp_stdio(&self) -> bool {
|
||||
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
|
||||
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
|
||||
}
|
||||
|
||||
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
|
||||
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
|
||||
/// v2 response has `info`.
|
||||
@@ -129,7 +97,7 @@ impl Negotiated {
|
||||
"audio": has(prompt, "audio"),
|
||||
"embeddedContext": has(prompt, "embeddedContext"),
|
||||
},
|
||||
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
|
||||
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
|
||||
});
|
||||
if session.is_some() {
|
||||
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
|
||||
@@ -168,11 +136,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 +145,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
|
||||
|
||||
@@ -16,42 +16,6 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
|
||||
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
|
||||
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
|
||||
|
||||
#[test]
|
||||
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
|
||||
for (code, message, expected) in [
|
||||
(AUTH_REQUIRED, "This account is not eligible.", true),
|
||||
(codes::INTERNAL_ERROR, "Auth required", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
|
||||
(codes::INTERNAL_ERROR, "Not authenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Authorization failed", true),
|
||||
(codes::INTERNAL_ERROR, "Authorize your account", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthentication error", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorization error", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in", true),
|
||||
(codes::INTERNAL_ERROR, "You are logged out", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in", true),
|
||||
(codes::INTERNAL_ERROR, "Please log in", true),
|
||||
(codes::INTERNAL_ERROR, "Login required", true),
|
||||
(codes::INTERNAL_ERROR, "author", false),
|
||||
(codes::INTERNAL_ERROR, "authors", false),
|
||||
(codes::INTERNAL_ERROR, "authority", false),
|
||||
(codes::INTERNAL_ERROR, "authored", false),
|
||||
(codes::INTERNAL_ERROR, "reauthenticate", false),
|
||||
(codes::INTERNAL_ERROR, "preauthorized", false),
|
||||
(codes::INTERNAL_ERROR, "signal input failed", false),
|
||||
(codes::INTERNAL_ERROR, "catalog index failed", false),
|
||||
(codes::INTERNAL_ERROR, "login succeeded", false),
|
||||
] {
|
||||
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages of a fixture going `dir` (`in` or `out`).
|
||||
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
|
||||
fixture
|
||||
@@ -93,81 +57,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 +99,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`.
|
||||
@@ -665,13 +527,3 @@ async fn v1_requests_are_sent_in_v2() {
|
||||
.collect();
|
||||
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
|
||||
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
|
||||
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
|
||||
assert!(
|
||||
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
|
||||
.mcp_stdio()
|
||||
);
|
||||
}
|
||||
+14
-130
@@ -2,8 +2,7 @@
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{
|
||||
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
|
||||
AgentUsage,
|
||||
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -36,32 +35,20 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the current settings approve tool requests without asking: a bypass mode,
|
||||
/// an allow-all permissions option, or a bypass approval policy.
|
||||
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
|
||||
let selected = |category: &str| {
|
||||
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
|
||||
};
|
||||
selected("mode").is_some_and(is_bypass_mode)
|
||||
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|
||||
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
|
||||
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
|
||||
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
|
||||
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
|
||||
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
|
||||
let value = option.values.iter().find(|v| {
|
||||
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
})?;
|
||||
Some((option, value))
|
||||
}
|
||||
|
||||
fn is_bypass_mode(mode: &str) -> bool {
|
||||
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
|
||||
let lower = mode.to_lowercase();
|
||||
let m: String =
|
||||
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -107,15 +94,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,
|
||||
@@ -154,22 +133,15 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name")?.as_str()?.trim_start_matches('/');
|
||||
let source = if provider == "codex-acp" && name.starts_with('$') {
|
||||
AgentCommandSource::Skill
|
||||
} else {
|
||||
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
|
||||
};
|
||||
Some(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
source,
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
@@ -295,100 +267,12 @@ mod tests {
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_bypass_settings_approve_without_asking() {
|
||||
let full_access = options_of(&json!({
|
||||
"modes": {"currentModeId": "full-access", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(bypass_approvals(&full_access));
|
||||
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
|
||||
|
||||
let asking = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(!bypass_approvals(&asking));
|
||||
assert_eq!(
|
||||
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
|
||||
Some("full-access"),
|
||||
"the offered bypass mode stays available"
|
||||
);
|
||||
|
||||
let permissions = |current: &str| {
|
||||
options_of(&json!({"configOptions": [{
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
|
||||
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
|
||||
}]}))
|
||||
};
|
||||
assert!(bypass_approvals(&permissions("on")));
|
||||
assert!(bypass_approvals(&permissions("TRUE")));
|
||||
assert!(!bypass_approvals(&permissions("off")));
|
||||
|
||||
let approval = options_of(&json!({"configOptions": [{
|
||||
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
|
||||
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
|
||||
}]}));
|
||||
assert!(bypass_approvals(&approval));
|
||||
|
||||
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
|
||||
let url_mode = options_of(&json!({
|
||||
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
|
||||
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
|
||||
}));
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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": [
|
||||
{"name": "review", "source": "skill"},
|
||||
{"name": "$lint"},
|
||||
{"name": "compact"}
|
||||
]});
|
||||
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
sources("codex-acp"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
|
||||
);
|
||||
assert_eq!(
|
||||
sources("custom"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
|
||||
);
|
||||
let old: AgentCommand =
|
||||
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
|
||||
assert_eq!(old.source, AgentCommandSource::Command);
|
||||
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
|
||||
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(
|
||||
&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}),
|
||||
"claude",
|
||||
);
|
||||
assert_eq!(cmds[0].source, AgentCommandSource::Command);
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
+86
-941
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -156,14 +156,6 @@ pub struct AgentConfigOption {
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandSource {
|
||||
#[default]
|
||||
Command,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
@@ -172,8 +164,6 @@ pub struct AgentCommand {
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: AgentCommandSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -292,9 +282,6 @@ pub struct AgentPermissionOption {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentQuestion {
|
||||
/// Stable answer identity when the provider supplies question ids.
|
||||
#[serde(default)]
|
||||
pub answer_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub header: String,
|
||||
pub question: String,
|
||||
@@ -419,9 +406,6 @@ pub struct AgentProvider {
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Thinking-level options by model value; absent models have not reported their levels.
|
||||
#[serde(default)]
|
||||
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
|
||||
/// Slash commands it offered last time.
|
||||
pub commands: Vec<AgentCommand>,
|
||||
pub source: AgentSource,
|
||||
@@ -518,15 +502,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 +510,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,
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,75 +69,6 @@ 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> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok(Discovered {
|
||||
commands: settings.commands.clone(),
|
||||
options: settings.options(),
|
||||
efforts: settings.efforts_by_model(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
@@ -167,7 +98,6 @@ pub(super) struct Shim {
|
||||
struct Session {
|
||||
id: String,
|
||||
cwd: PathBuf,
|
||||
mcp_config: Option<tempfile::TempPath>,
|
||||
shim: Weak<Shim>,
|
||||
st: Mutex<State>,
|
||||
/// Serializes (re)starting the process.
|
||||
@@ -334,24 +264,9 @@ impl Shim {
|
||||
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
|
||||
}
|
||||
|
||||
fn add_session(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
cwd: PathBuf,
|
||||
start: Start,
|
||||
pending: bool,
|
||||
mcp_config: Option<String>,
|
||||
) -> Arc<Session> {
|
||||
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
|
||||
Ok(path) => Some(path),
|
||||
Err(e) => {
|
||||
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
|
||||
let session = Arc::new(Session {
|
||||
id: id.to_owned(),
|
||||
mcp_config,
|
||||
shim: Arc::downgrade(self),
|
||||
st: Mutex::new(State {
|
||||
proc: None,
|
||||
@@ -397,7 +312,6 @@ impl Shim {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": { "image": true, "embeddedContext": true },
|
||||
"sessionCapabilities": { "fork": {} },
|
||||
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
|
||||
},
|
||||
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
|
||||
@@ -406,13 +320,7 @@ impl Shim {
|
||||
}
|
||||
"session/new" => {
|
||||
let id = new_uuid(false);
|
||||
let session = self.add_session(
|
||||
&id,
|
||||
self.cwd_of(¶ms),
|
||||
Start::New { id: id.clone() },
|
||||
false,
|
||||
mcp_config(¶ms),
|
||||
);
|
||||
let session = self.add_session(&id, self.cwd_of(¶ms), Start::New { id: id.clone() }, false);
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
session.announce_commands();
|
||||
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
|
||||
@@ -428,7 +336,7 @@ impl Shim {
|
||||
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
|
||||
};
|
||||
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
|
||||
let session = self.add_session(id, cwd, start, pending, mcp_config(¶ms));
|
||||
let session = self.add_session(id, cwd, start, pending);
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
if method == "session/load" {
|
||||
session.replay(&chain);
|
||||
@@ -454,7 +362,7 @@ impl Shim {
|
||||
(start, st.settings.clone())
|
||||
};
|
||||
self.save_pending(&id, Some(&start));
|
||||
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(¶ms));
|
||||
let session = self.add_session(&id, source.cwd.clone(), start, true);
|
||||
session.st.lock().settings = settings;
|
||||
let options = session.st.lock().settings.options();
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
@@ -648,9 +556,6 @@ impl Session {
|
||||
old.end();
|
||||
}
|
||||
let mut args = shim.config.args.clone();
|
||||
if let Some(config) = &self.mcp_config {
|
||||
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
|
||||
}
|
||||
args.extend(start.args());
|
||||
let launch =
|
||||
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
|
||||
@@ -1440,38 +1345,3 @@ fn start_from_json(v: &Value) -> Option<Start> {
|
||||
}
|
||||
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
|
||||
}
|
||||
|
||||
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
|
||||
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
|
||||
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
|
||||
std::io::Write::write_all(&mut file, json.as_bytes())?;
|
||||
Ok(file.into_temp_path())
|
||||
}
|
||||
|
||||
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
|
||||
fn mcp_config(params: &Value) -> Option<String> {
|
||||
let servers = params.get("mcpServers")?.as_array()?;
|
||||
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
|
||||
.collect()
|
||||
};
|
||||
let mut out = serde_json::Map::new();
|
||||
for server in servers {
|
||||
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
|
||||
let entry = match server.get("type").and_then(Value::as_str) {
|
||||
Some(kind @ ("http" | "sse")) => {
|
||||
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
|
||||
}
|
||||
_ => json!({
|
||||
"command": server.get("command"),
|
||||
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
|
||||
"env": pairs(server.get("env")),
|
||||
}),
|
||||
};
|
||||
out.insert(name.to_owned(), entry);
|
||||
}
|
||||
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1170,29 +1170,3 @@ async fn skipped_questions_have_no_answers() {
|
||||
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
|
||||
assert_eq!(cancelled, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_become_an_mcp_config() {
|
||||
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
|
||||
assert_eq!(mcp_config(&json!({})), None);
|
||||
let config = mcp_config(&json!({ "mcpServers": [
|
||||
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
|
||||
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
|
||||
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
|
||||
] }))
|
||||
.unwrap();
|
||||
let v: Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(
|
||||
v["mcpServers"]["openidea"],
|
||||
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
|
||||
);
|
||||
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
|
||||
|
||||
let path = write_mcp_config(&config).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
|
||||
let kept = path.to_path_buf();
|
||||
drop(path);
|
||||
assert!(!kept.exists());
|
||||
}
|
||||
@@ -8,7 +8,6 @@ mod translate;
|
||||
mod tests;
|
||||
|
||||
use super::Shim;
|
||||
use crate::acp::AUTH_REQUIRED;
|
||||
use crate::provider::ProviderSpec;
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use parking_lot::Mutex;
|
||||
@@ -28,6 +27,8 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -53,7 +54,6 @@ fn attach(
|
||||
client: OnceLock::new(),
|
||||
state: Mutex::new(State::default()),
|
||||
models: tokio::sync::Mutex::new(None),
|
||||
skills: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
|
||||
let _ = shim.codex.set(codex.clone());
|
||||
@@ -83,8 +83,6 @@ struct CodexShim {
|
||||
state: Mutex<State>,
|
||||
/// `model/list`, fetched once.
|
||||
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
|
||||
/// Keep skill snapshots in request order, including invalidations during session setup.
|
||||
skills: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -206,7 +204,7 @@ fn from_codex(e: RpcError) -> ResponseError {
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
|
||||
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -266,7 +264,7 @@ impl CodexShim {
|
||||
"agentCapabilities": {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
|
||||
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
|
||||
"mcpCapabilities": { "http": false, "sse": false },
|
||||
"sessionCapabilities": { "fork": {}, "close": {} },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true } },
|
||||
},
|
||||
@@ -303,20 +301,6 @@ impl CodexShim {
|
||||
models
|
||||
}
|
||||
|
||||
async fn publish_skills(&self, session: &str) {
|
||||
let _listing = self.skills.lock().await;
|
||||
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
|
||||
let Some(cwd) = cwd else { return };
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
|
||||
if let Ok(Ok(result)) = result
|
||||
&& let Some(update) = translate::skill_commands(&result)
|
||||
&& self.state.lock().threads.contains_key(session)
|
||||
{
|
||||
self.emit(session, update);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
|
||||
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
@@ -334,16 +318,11 @@ impl CodexShim {
|
||||
|
||||
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
let result = self.call("thread/start", request).await?;
|
||||
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, None);
|
||||
if id.is_empty() {
|
||||
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
|
||||
}
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -351,18 +330,13 @@ impl CodexShim {
|
||||
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let id = str_param(params, "sessionId")?;
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
let result = self.call("thread/resume", request).await?;
|
||||
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
|
||||
let (_, options) = self.open_thread(&result, &models, None);
|
||||
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
for update in translate::history(thread, cwd.as_deref()) {
|
||||
self.emit(id, update);
|
||||
}
|
||||
self.publish_skills(id).await;
|
||||
Ok(json!({ "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -371,9 +345,6 @@ impl CodexShim {
|
||||
let models = self.models().await;
|
||||
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
|
||||
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
if let Some(s) = &settings {
|
||||
request["model"] = json!(s.model_slug(&models));
|
||||
request["approvalPolicy"] = json!(s.mode.approval_policy());
|
||||
@@ -381,7 +352,6 @@ impl CodexShim {
|
||||
}
|
||||
let result = self.call("thread/fork", request).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, settings);
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -571,25 +541,9 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
|
||||
fn on_codex_notification(&self, method: &str, params: &Value) {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
|
||||
match method {
|
||||
"skills/changed" => {
|
||||
let shim = self.clone();
|
||||
let sessions: Vec<String> = self
|
||||
.state
|
||||
.lock()
|
||||
.threads
|
||||
.iter()
|
||||
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.runtime.spawn(async move {
|
||||
for session in sessions {
|
||||
shim.publish_skills(&session).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"turn/started" => {
|
||||
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
|
||||
self.turn_known(thread, turn, true);
|
||||
@@ -868,7 +822,7 @@ impl CodexShim {
|
||||
match method {
|
||||
"item/commandExecution/requestApproval" => {
|
||||
let choices = translate::decisions(params, true);
|
||||
let title = translate::command_approval_title(params, item.as_ref());
|
||||
let title = translate::command_approval_title(params, item.as_ref(), cwd);
|
||||
let call = tool_call_of(
|
||||
&item_id,
|
||||
&title,
|
||||
@@ -917,55 +871,31 @@ impl CodexShim {
|
||||
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
|
||||
}
|
||||
"item/tool/requestUserInput" => {
|
||||
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
// One permission request carrying all the questions, so the client shows
|
||||
// them as a card (as it does for Claude's `AskUserQuestion`).
|
||||
let asked: Vec<Value> = questions
|
||||
.iter()
|
||||
.filter_map(|q| {
|
||||
let id = q.get("id").and_then(Value::as_str)?;
|
||||
let question = q.get("question").and_then(Value::as_str)?;
|
||||
let options: Vec<Value> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| {
|
||||
let label = o.get("label").and_then(Value::as_str)?;
|
||||
Some(json!({
|
||||
"label": label,
|
||||
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"answerKey": id,
|
||||
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
|
||||
"question": question,
|
||||
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
|
||||
"options": options,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
if asked.is_empty() {
|
||||
return Ok(json!({ "answers": {} }));
|
||||
}
|
||||
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
|
||||
let mut call = tool_call_of(&item_id, title, "other", None, None);
|
||||
call["_meta"] = json!({ "questions": asked });
|
||||
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
|
||||
let picked: serde_json::Map<String, Value> = self
|
||||
.ask_option(&thread, call, &options)
|
||||
.and_then(|reply| serde_json::from_str(&reply).ok())
|
||||
.unwrap_or_default();
|
||||
let mut answers = serde_json::Map::new();
|
||||
for q in &questions {
|
||||
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
|
||||
if let Some(labels) = picked
|
||||
.get(qid)
|
||||
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
|
||||
{
|
||||
answers.insert(qid.to_owned(), json!({ "answers": labels }));
|
||||
let choices: Vec<Choice> = q
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(i, o)| {
|
||||
let label = o.get("label")?.as_str()?;
|
||||
Some(Choice {
|
||||
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
|
||||
answer: json!({ "answers": [label] }),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
|
||||
let call = tool_call_of(&item_id, title, "other", None, None);
|
||||
let answer = self.ask(&thread, call, &choices, Value::Null);
|
||||
if !answer.is_null() {
|
||||
answers.insert(qid.to_owned(), answer);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "answers": answers }))
|
||||
@@ -998,25 +928,6 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the client; returns the selected option id (`None` when cancelled).
|
||||
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
|
||||
let client = self.client.get().cloned()?;
|
||||
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
|
||||
let outcome: Result<Value, RpcError> =
|
||||
self.runtime.block_on(client.request("session/request_permission", params, None));
|
||||
match outcome {
|
||||
Ok(outcome) => outcome
|
||||
.pointer("/outcome/outcome")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|outcome| *outcome == "selected")
|
||||
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
|
||||
Err(e) => {
|
||||
tracing::debug!("codex: question request failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
|
||||
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
|
||||
let Some(client) = self.client.get().cloned() else { return otherwise };
|
||||
|
||||
@@ -247,39 +247,13 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
|
||||
assert_eq!(
|
||||
command_approval_title(
|
||||
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
|
||||
None,
|
||||
None
|
||||
),
|
||||
"Run ls"
|
||||
"Run touch b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
|
||||
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
|
||||
assert_eq!(
|
||||
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
|
||||
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
|
||||
);
|
||||
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
|
||||
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
|
||||
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
|
||||
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_command_uses_its_request_and_matching_item_only() {
|
||||
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
|
||||
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
|
||||
"commandActions": [{"type": "listFiles", "path": "."}]});
|
||||
let request = json!({"itemId": "write", "command": command});
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
|
||||
let item = json!({"id": "write", "command": command});
|
||||
let request = json!({"itemId": "write"});
|
||||
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
|
||||
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
|
||||
assert_eq!(command_approval_title(&request, None), "Run a command");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_replays_turns() {
|
||||
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
|
||||
@@ -342,39 +316,6 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn questions_share_one_card_and_keep_their_ids() {
|
||||
let client = Arc::new(Client::default());
|
||||
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let params = json!({"threadId": "s", "itemId": "q", "questions": [
|
||||
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
|
||||
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
|
||||
]});
|
||||
for expected in
|
||||
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
|
||||
{
|
||||
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
|
||||
assert_eq!(reply, json!({"answers": expected}));
|
||||
}
|
||||
let asked = client.asked.lock();
|
||||
assert_eq!(asked.len(), 3);
|
||||
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
|
||||
assert_eq!(questions.as_array().unwrap().len(), 2);
|
||||
assert_eq!(questions[0]["answerKey"], "first");
|
||||
assert_eq!(questions[1]["answerKey"], "second");
|
||||
assert_eq!(questions[0]["options"][0]["description"], "First");
|
||||
acp.kill();
|
||||
codex.kill();
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Scenario {
|
||||
AuthFailure,
|
||||
@@ -528,21 +469,12 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
let mut sent = Vec::new();
|
||||
for (out, frame) in &t.frames {
|
||||
if *out {
|
||||
let actual: Value = loop {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
break actual;
|
||||
};
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
match frame.get("method") {
|
||||
Some(method) => {
|
||||
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
|
||||
@@ -632,23 +564,6 @@ async fn auth_failure_names_auth() {
|
||||
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
|
||||
for (code, message, expected) in [
|
||||
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
|
||||
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
|
||||
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
|
||||
] {
|
||||
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
|
||||
assert_eq!(response.code, expected, "{message}");
|
||||
assert_eq!(response.message, message);
|
||||
assert_eq!(response.data, Some(json!({"detail": 1})));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
|
||||
@@ -1053,129 +968,3 @@ fn v_method(line: &str) -> Option<&str> {
|
||||
let at = line.find("\"method\":\"")? + 10;
|
||||
line[at..].split('"').next()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_mcp_servers_become_thread_configuration() {
|
||||
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
|
||||
assert_eq!(
|
||||
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
|
||||
Some(
|
||||
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
|
||||
for (method, native) in
|
||||
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
|
||||
{
|
||||
for initial in [
|
||||
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
|
||||
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
|
||||
]}]}}),
|
||||
json!({"error": {"code": -32601, "message": "unsupported"}}),
|
||||
json!({"result": {}}),
|
||||
] {
|
||||
let listed = initial.pointer("/result/data").is_some();
|
||||
let mut initial = initial;
|
||||
initial["id"] = json!(4);
|
||||
let transcript = Transcript {
|
||||
meta: Value::Null,
|
||||
frames: vec![
|
||||
(true, json!({"id": 1, "method": "initialize"})),
|
||||
(false, json!({"id": 1, "result": {}})),
|
||||
(true, json!({"method": "initialized"})),
|
||||
(true, json!({"id": 2, "method": "model/list"})),
|
||||
(false, json!({"id": 2, "result": {"data": []}})),
|
||||
(true, json!({"id": 3, "method": native})),
|
||||
(
|
||||
false,
|
||||
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
|
||||
),
|
||||
(true, json!({"id": 4, "method": "skills/list"})),
|
||||
(false, initial),
|
||||
(true, json!({"id": 5, "method": "turn/start"})),
|
||||
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 6, "method": "skills/list"})),
|
||||
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 7, "method": "skills/list"})),
|
||||
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 8, "method": "skills/list"})),
|
||||
(false, json!({"id": 8, "result": {"data": []}})),
|
||||
],
|
||||
};
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let client = Arc::new(Client::default());
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
request(&acp, "initialize", json!({})).await.unwrap();
|
||||
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
|
||||
let initial_updates = client.of("s");
|
||||
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
|
||||
if listed {
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
|
||||
}
|
||||
assert!(
|
||||
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let (frames, _read, _write) = player.await.unwrap();
|
||||
for frame in sent(&frames, "skills/list") {
|
||||
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
|
||||
}
|
||||
assert_eq!(
|
||||
sent(&frames, "turn/start")[0]["params"]["input"],
|
||||
json!([{"type": "text", "text": "/$review check this"}])
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while client.of("s").len() < usize::from(listed) + 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let updates = client.of("s");
|
||||
assert_eq!(updates.len(), usize::from(listed) + 2);
|
||||
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
|
||||
acp.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_become_commands_with_their_invocation_names() {
|
||||
let update = skill_commands(&json!({"data": [{"skills": [
|
||||
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
|
||||
{"name": "review", "enabled": true, "description": "Duplicate"},
|
||||
{"name": "hidden", "enabled": false, "description": "Disabled"},
|
||||
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
|
||||
{"name": "full", "enabled": true, "description": "Full description"},
|
||||
{"name": "", "enabled": true}, {"description": "No name"}
|
||||
]}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
update,
|
||||
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
|
||||
{"name": "$review", "description": "Short", "source": "skill", "input": null},
|
||||
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
|
||||
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
|
||||
]})
|
||||
);
|
||||
let commands = crate::config::commands_of(&update, "codex");
|
||||
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
|
||||
assert_eq!(commands[0].name, "$review");
|
||||
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
|
||||
assert!(skill_commands(&json!({})).is_none());
|
||||
}
|
||||
@@ -256,31 +256,6 @@ fn effort_name(effort: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
|
||||
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
|
||||
let entries = result.get("data")?.as_array()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|skill| {
|
||||
let name = skill.get("name")?.as_str()?.trim();
|
||||
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
|
||||
return None;
|
||||
}
|
||||
let description = skill
|
||||
.pointer("/interface/shortDescription")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
|
||||
.or_else(|| skill.get("description").and_then(Value::as_str))
|
||||
.unwrap_or(name);
|
||||
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
|
||||
}
|
||||
|
||||
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
|
||||
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
|
||||
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
@@ -758,19 +733,18 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
|
||||
}
|
||||
|
||||
/// The ACP title of a command approval.
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
|
||||
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
|
||||
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
|
||||
return format!("Allow network access to {host}");
|
||||
}
|
||||
// Approval payloads can describe a different command from the item's earlier summary.
|
||||
let item = item.filter(|i| i.get("id") == params.get("itemId"));
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|c| !c.is_empty())
|
||||
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
|
||||
match command {
|
||||
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
|
||||
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
|
||||
let title = from_item.or_else(|| {
|
||||
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
|
||||
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
|
||||
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
|
||||
});
|
||||
match title.filter(|t| !t.is_empty()) {
|
||||
Some(t) => format!("Run {t}"),
|
||||
None => "Run a command".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -861,31 +835,3 @@ pub(super) fn tail(text: &str) -> String {
|
||||
fn str_of(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
|
||||
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
|
||||
let mut servers = serde_json::Map::new();
|
||||
for server in params.get("mcpServers")?.as_array()? {
|
||||
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
|
||||
continue;
|
||||
}
|
||||
let (Some(name), Some(command)) =
|
||||
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let env: serde_json::Map<String, Value> = server
|
||||
.get("env")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
|
||||
.collect();
|
||||
servers.insert(
|
||||
name.into(),
|
||||
json!({"command":command,
|
||||
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
|
||||
);
|
||||
}
|
||||
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
|
||||
}
|
||||
@@ -2,10 +2,9 @@
|
||||
//! `<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;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -59,14 +58,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> {
|
||||
@@ -85,91 +81,6 @@ fn builtins() -> Vec<ProviderSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ProviderSpec {
|
||||
pub(crate) fn login_hint(&self, program: &Path) -> String {
|
||||
if !program.is_absolute() {
|
||||
return self.login_hint.clone();
|
||||
}
|
||||
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
|
||||
let builtin = builtin_spec(&self.id);
|
||||
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
|
||||
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
|
||||
let name = &self.name;
|
||||
let program = Path::new(&self.command);
|
||||
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
|
||||
.then(|| {
|
||||
auth_methods.iter().find_map(|method| {
|
||||
if method.get("type").and_then(Value::as_str) != Some("terminal") {
|
||||
return None;
|
||||
}
|
||||
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let hint = match terminal_args {
|
||||
Some(args) => {
|
||||
let command = render_login_command(program, args, cfg!(windows));
|
||||
format!("Run `{command}` in a terminal.")
|
||||
}
|
||||
None => self.login_hint(program),
|
||||
};
|
||||
format!("{name} needs you to sign in. {hint}")
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
|
||||
return spec.login_hint.clone();
|
||||
};
|
||||
let same_path = |a: &Path, b: &Path| {
|
||||
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
|
||||
};
|
||||
if on_path.is_some_and(|path| same_path(path, program)) {
|
||||
return spec.login_hint.clone();
|
||||
}
|
||||
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
|
||||
let command = render_login_command(program, args.split_whitespace(), windows);
|
||||
match suffix {
|
||||
" in a terminal." => format!("Run `{command}` in a terminal."),
|
||||
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
|
||||
_ => spec.login_hint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
|
||||
let quote = |word: &str| {
|
||||
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
|
||||
};
|
||||
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
|
||||
for arg in args {
|
||||
command.push(' ');
|
||||
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
|
||||
command.push_str(arg);
|
||||
} else {
|
||||
command.push_str("e(arg));
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
/// Whether `id` names a built-in agent that needs no registry install (native drivers
|
||||
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
|
||||
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
|
||||
@@ -187,14 +98,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);
|
||||
@@ -216,14 +119,13 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
args: spec.args.clone(),
|
||||
default_program: builtin.as_ref().map(|b| b.command.clone()),
|
||||
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
|
||||
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
|
||||
login_hint: Some(spec.login_hint.clone()),
|
||||
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
|
||||
command_editable: reason.is_none(),
|
||||
command_read_only_reason: reason,
|
||||
installed: installed(&spec.command),
|
||||
env: spec.env.iter().cloned().collect(),
|
||||
config: vec![],
|
||||
thought_levels: BTreeMap::new(),
|
||||
commands: vec![],
|
||||
source: spec.source.clone(),
|
||||
}
|
||||
@@ -278,127 +180,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)]
|
||||
@@ -530,152 +311,6 @@ fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
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() {
|
||||
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
|
||||
for windows in [false, true] {
|
||||
let program =
|
||||
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
|
||||
let hint = render_login_hint(&spec, program, None, windows);
|
||||
if separate {
|
||||
assert_eq!(hint, spec.login_hint, "{}", spec.id);
|
||||
} else {
|
||||
let prefix =
|
||||
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
|
||||
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
|
||||
assert_eq!(
|
||||
render_login_hint(&spec, program, Some(program), windows),
|
||||
spec.login_hint,
|
||||
"a PATH-resolved command stays bare: {}",
|
||||
spec.id
|
||||
);
|
||||
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
|
||||
let cursor = builtin_spec("cursor").unwrap();
|
||||
assert_eq!(
|
||||
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
|
||||
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
|
||||
);
|
||||
assert_eq!(
|
||||
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
|
||||
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
|
||||
);
|
||||
let opencode = builtin_spec("opencode").unwrap();
|
||||
assert_eq!(
|
||||
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
|
||||
"Run `'/registry/opencode' auth login` in a terminal."
|
||||
);
|
||||
let mut custom = cursor;
|
||||
custom.id = "custom".into();
|
||||
custom.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cursor = builtin_spec("cursor").unwrap();
|
||||
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
|
||||
let program = dir.path().join(command);
|
||||
std::fs::write(&program, "").unwrap();
|
||||
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
|
||||
let resolved = cursor.clone().resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), program);
|
||||
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
|
||||
cursor.command = format!("tools/{command}");
|
||||
let resolved = cursor.resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_login_commands_quote_the_binary_and_each_argument() {
|
||||
for (windows, expected) in [
|
||||
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
|
||||
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
|
||||
] {
|
||||
assert_eq!(
|
||||
render_login_command(
|
||||
Path::new("/agent's tools/$cli"),
|
||||
["auth", "login", "--account", "user name", "a'b", "$token", ""],
|
||||
windows
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.id = "custom".into();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
for method in [
|
||||
json!({"type": "terminal"}),
|
||||
json!({"type": "terminal", "args": ["login", null]}),
|
||||
json!({"type": "agent", "args": ["login"]}),
|
||||
] {
|
||||
assert_eq!(
|
||||
spec.sign_in_message(&[method]),
|
||||
"Cursor needs you to sign in. Sign in with the agent's own command line first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn registry_login_hint_names_the_installed_executable() {
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.command = "/registry path/dist-package/cursor-agent".into();
|
||||
assert_eq!(
|
||||
describe(&spec).login_hint.as_deref(),
|
||||
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
|
||||
@@ -761,15 +396,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 +500,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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::{AgentCommand, AgentCommandSource};
|
||||
use crate::AgentCommand;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -36,14 +36,9 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
for c in more {
|
||||
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
|
||||
offered.source = c.source;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -66,7 +61,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text, AgentCommandSource::Skill))
|
||||
Some(meta.command(name, &text))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -84,7 +79,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +147,7 @@ impl Frontmatter {
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
@@ -163,7 +158,6 @@ impl Frontmatter {
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
source,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
@@ -200,20 +194,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
}
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Home is an ancestor of the project, so the walk stops there instead of climbing
|
||||
// past it into the real profile (a temp dir under the user's home holds its skills).
|
||||
let home = tmp.path().to_path_buf();
|
||||
let home = tmp.path().join("home");
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
@@ -240,12 +227,6 @@ mod tests {
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
for name in ["issue-fix", "triage", "verify", "lint"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Skill);
|
||||
}
|
||||
for name in ["git:commit", "review", "parent", "brooks-review"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Command);
|
||||
}
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
@@ -281,14 +262,10 @@ mod tests {
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![
|
||||
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
|
||||
cmd("verify", "Verify"),
|
||||
],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
assert_eq!(merged[0].source, AgentCommandSource::Skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+16
-1518
File diff suppressed because it is too large.
Load diff
@@ -290,8 +290,7 @@ impl Timeline {
|
||||
/// and records when the turn ended (`done`) on the message that started it.
|
||||
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
|
||||
let mut changed = Vec::new();
|
||||
// A steered message rides along in a running turn; the one that started it owns its end.
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
|
||||
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
|
||||
for (i, e) in self.entries.iter_mut().enumerate() {
|
||||
match e {
|
||||
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
|
||||
@@ -469,27 +468,6 @@ mod tests {
|
||||
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_turn_records_on_the_message_that_started_the_turn() {
|
||||
let user = |sent: f64, steered: bool| AgentEntry::User {
|
||||
text: "q".into(),
|
||||
attachments: vec![],
|
||||
images: vec![],
|
||||
sent_ms: Some(sent),
|
||||
done_ms: None,
|
||||
steered,
|
||||
};
|
||||
let mut t = Timeline::default();
|
||||
t.push(user(1.0, false));
|
||||
t.push(AgentEntry::Agent { text: "a".into() });
|
||||
t.push(user(2.0, true));
|
||||
// The steered message rides along in the turn: the reply's time belongs to the
|
||||
// message that started it.
|
||||
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
|
||||
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
|
||||
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_output_keeps_its_tail() {
|
||||
let s = tail("é".repeat(MAX_OUTPUT));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
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 ¤t.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(¤t)
|
||||
}
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -75,11 +67,6 @@ impl BuildStep {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMake configure/build invocation, parsed as compiler output in the Build view.
|
||||
pub fn cmake(program: &str, args: Vec<String>, cwd: PathBuf, env: BTreeMap<String, String>) -> Self {
|
||||
Self { program: program.into(), args, cwd, env, format: Format::Gcc }
|
||||
}
|
||||
|
||||
/// The command line as echoed in the console.
|
||||
pub fn display(&self) -> String {
|
||||
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
|
||||
@@ -166,7 +153,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
} else if has(dir, &["go.mod", "go.work"]) {
|
||||
} else if has(dir, &["go.mod"]) {
|
||||
Some(BuildSystem::Go)
|
||||
} else if has(dir, &["tsconfig.json", "package.json"]) {
|
||||
Some(BuildSystem::TypeScript)
|
||||
@@ -240,7 +227,7 @@ pub fn plan(
|
||||
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
|
||||
BuildSystem::Cargo => cargo_steps(&dir, kind),
|
||||
// Go builds the file's package (a module holds many).
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
};
|
||||
@@ -266,26 +253,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();
|
||||
@@ -400,33 +374,7 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
|
||||
steps
|
||||
}
|
||||
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
|
||||
if dir.join("go.work").is_file() {
|
||||
let work_file = dunce::canonicalize(dir.join("go.work"))?;
|
||||
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
|
||||
if modules.is_empty() {
|
||||
return Err(IdeError::invalid("The Go workspace contains no modules"));
|
||||
}
|
||||
let mut steps = Vec::new();
|
||||
for module in modules {
|
||||
let module = dir.join(module);
|
||||
if !module.join("go.mod").is_file() {
|
||||
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
|
||||
}
|
||||
// Go compares workspace membership against cwd paths lexically.
|
||||
let module = dunce::canonicalize(module)?;
|
||||
let mut module_steps = go_module_steps(&module, kind);
|
||||
for step in &mut module_steps {
|
||||
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
|
||||
}
|
||||
steps.extend(module_steps);
|
||||
}
|
||||
return Ok(steps);
|
||||
}
|
||||
Ok(go_module_steps(dir, kind))
|
||||
}
|
||||
|
||||
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
|
||||
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
|
||||
let mut args = vec!["build"];
|
||||
@@ -793,7 +741,13 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
@@ -805,12 +759,6 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind:
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaunchBuild {
|
||||
pub class_dirs: Vec<PathBuf>,
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -818,12 +766,11 @@ pub fn build_for_launch(
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
kind: BuildKind,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<LaunchBuild> {
|
||||
let plan = launch_plan(root, model, module, test, kind)?;
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
let failure = if !result.success {
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
.messages
|
||||
.iter()
|
||||
@@ -834,21 +781,17 @@ pub fn build_for_launch(
|
||||
_ => m.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
Some(format!("Build failed:\n{}", errors.join("\n")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let class_dirs = if plan.system == BuildSystem::Maven
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
maven_launch_projects(root, m, module, test)
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect()
|
||||
} else {
|
||||
launch_class_dirs(plan.system, module, test)
|
||||
};
|
||||
Ok(LaunchBuild { class_dirs, failure })
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
}
|
||||
|
||||
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
|
||||
@@ -870,79 +813,6 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
|
||||
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
|
||||
return;
|
||||
};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = &tmp.path().join("workspace");
|
||||
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
|
||||
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
|
||||
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
|
||||
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
|
||||
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
|
||||
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
|
||||
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
|
||||
// std canonicalization also exercises verbatim paths on Windows.
|
||||
for root in [root.to_path_buf(), std::fs::canonicalize(root).unwrap()] {
|
||||
let plan = plan(&root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
for step in plan.steps {
|
||||
let result = std::process::Command::new(&go)
|
||||
.args(&step.args)
|
||||
.envs(&step.env)
|
||||
.env("GOPROXY", "off")
|
||||
.current_dir(&step.cwd)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(result.status.success(), "{step:?}: {}", String::from_utf8_lossy(&result.stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_workspace_builds_every_use_module() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("workspace");
|
||||
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
|
||||
for module in ["api", "lib with spaces", "../shared"] {
|
||||
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
|
||||
}
|
||||
// Cover native paths, Windows verbatim paths and non-normalized module paths.
|
||||
let canonical = std::fs::canonicalize(&root).unwrap();
|
||||
for root in [&root, &canonical, &canonical.join("..").join("workspace")] {
|
||||
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
|
||||
let p = plan(root, &BuildModel::default(), kind, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Go);
|
||||
assert_eq!(p.steps.len(), 3);
|
||||
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
|
||||
assert_eq!(step.cwd, dunce::canonicalize(root.join(module)).unwrap());
|
||||
assert_eq!(
|
||||
step.env["GOWORK"],
|
||||
dunce::canonicalize(root.join("go.work")).unwrap().to_string_lossy()
|
||||
);
|
||||
let expected =
|
||||
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
|
||||
assert_eq!(step.args, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
let member =
|
||||
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
|
||||
assert_eq!(member.steps.len(), 1);
|
||||
assert_eq!(member.steps[0].cwd, root.join("api"));
|
||||
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
|
||||
write(&root, "go.work", "use ./missing\n");
|
||||
assert!(
|
||||
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("go.mod")
|
||||
);
|
||||
write(&root, "go.work", "go 1.22\n");
|
||||
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
@@ -1049,22 +919,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 +935,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'");
|
||||
|
||||
@@ -1367,44 +1205,14 @@ mod tests {
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1425,10 +1233,9 @@ mod tests {
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
dirs.class_dirs,
|
||||
dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
@@ -1437,16 +1244,12 @@ mod tests {
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error =
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.failure
|
||||
.unwrap();
|
||||
assert!(error.contains("Kotlin compilation failed"), "{error}");
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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(¤t));
|
||||
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, ¤t);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
|
||||
/// Quoted string literals in `s`.
|
||||
fn literals(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find(['"', '\'']) {
|
||||
let Some((value, used)) = literal(&rest[start..]) else { break };
|
||||
out.push(value);
|
||||
rest = &rest[start + used..];
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' || c == '\'' {
|
||||
out.push(chars.by_ref().take_while(|&n| n != c).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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";
|
||||
@@ -578,12 +565,11 @@ fn is_dependency_configuration(name: &str) -> bool {
|
||||
/// `(group, artifact, version)` of the external dependencies a build script declares:
|
||||
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
|
||||
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
|
||||
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
|
||||
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
|
||||
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(statements) {
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
let line = line.trim_start();
|
||||
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if name.is_empty() || !is_dependency_configuration(&name) {
|
||||
@@ -605,7 +591,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
|
||||
}),
|
||||
};
|
||||
if let Some((g, a, v)) = coordinate {
|
||||
let v = interpolate(&v, properties);
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
@@ -617,354 +602,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
|
||||
out
|
||||
}
|
||||
|
||||
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
|
||||
fn statements(line: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut index = 0;
|
||||
while index < line.len() {
|
||||
let c = line[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
|
||||
continue;
|
||||
}
|
||||
if matches!(c, '{' | '}' | ';' | '\n') {
|
||||
out.push(&line[start..index]);
|
||||
start = index + c.len_utf8();
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
out.push(&line[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
|
||||
/// targets, as written in the script.
|
||||
pub fn applied_scripts(text: &str) -> Vec<String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if word != "apply" || !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = text[index + "apply".len()..].trim_start();
|
||||
if let Some(inner) = rest.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
let Some(rest) = rest.strip_prefix("from") else { continue };
|
||||
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
|
||||
continue;
|
||||
};
|
||||
let mut rest = rest.trim_start();
|
||||
if let Some(inner) = rest.strip_prefix("file") {
|
||||
let inner = inner.trim_start();
|
||||
if let Some(inner) = inner.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
}
|
||||
if let Some((path, _)) = literal(rest) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
|
||||
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = BTreeMap::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let rest = text[index + word.len()..].trim_start();
|
||||
if word == "val" || word == "var" {
|
||||
let (name, rest) = identifier(rest);
|
||||
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
|
||||
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
|
||||
if let Some(rest) = rest.strip_prefix('(')
|
||||
&& let Some((value, used)) = literal(rest.trim_start())
|
||||
&& rest.trim_start()[used..].trim_start().starts_with(')')
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word != "ext" && word != "extra" {
|
||||
continue;
|
||||
}
|
||||
if let Some(block) = rest.strip_prefix('{') {
|
||||
if let Some(end) = matching_brace(block) {
|
||||
out.extend(assignments(&block[..end]));
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('.') {
|
||||
let (name, rest) = identifier(rest);
|
||||
if let Some(value) = assigned_literal(rest)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('[')
|
||||
&& let Some((name, used)) = literal(rest.trim_start())
|
||||
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
|
||||
&& let Some(value) = assigned_literal(rest)
|
||||
{
|
||||
out.insert(name, value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
|
||||
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with(['#', '!']) {
|
||||
return None;
|
||||
}
|
||||
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
|
||||
let (key, rest) = line.split_at(separator);
|
||||
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
|
||||
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
|
||||
/// read from disk: absolute path and text. Targets that do not exist are skipped.
|
||||
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
|
||||
const MAX_SCRIPTS: usize = 256;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let first = normalize(&root.join(build_file));
|
||||
let Ok(identity) = dunce::canonicalize(&first) else { return out };
|
||||
seen.insert(identity);
|
||||
let mut stack = vec![(first, 0)];
|
||||
while let Some((path, depth)) = stack.pop() {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
if depth < MAX_DEPTH {
|
||||
let mut targets = Vec::new();
|
||||
for spec in applied_scripts(&text) {
|
||||
if seen.len() >= MAX_SCRIPTS {
|
||||
break;
|
||||
}
|
||||
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
|
||||
&& let Ok(identity) = dunce::canonicalize(&target)
|
||||
&& seen.insert(identity)
|
||||
{
|
||||
targets.push((target, depth + 1));
|
||||
}
|
||||
}
|
||||
stack.extend(targets.into_iter().rev());
|
||||
}
|
||||
out.push((path, text));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
|
||||
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
|
||||
let spec = spec.trim();
|
||||
if spec.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let properties = BTreeMap::from([
|
||||
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
|
||||
]);
|
||||
let spec = interpolate(spec, &properties);
|
||||
if spec.contains('$') {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(&spec);
|
||||
if path.is_absolute() {
|
||||
return path.is_file().then(|| normalize(path));
|
||||
}
|
||||
let project_path = normalize(&project_dir.join(path));
|
||||
if project_path.is_file() {
|
||||
return Some(project_path);
|
||||
}
|
||||
let path = normalize(&script.parent()?.join(path));
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
|
||||
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
|
||||
root.join(dir.map_or(&model.dir, |p| &p.dir))
|
||||
}
|
||||
|
||||
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
|
||||
/// expressions are left as written, so ranges and catalogs stay skipped.
|
||||
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
|
||||
if !value.contains('$') {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '$' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let braced = chars.peek() == Some(&'{');
|
||||
if braced {
|
||||
chars.next();
|
||||
}
|
||||
let mut name = String::new();
|
||||
if braced {
|
||||
for n in chars.by_ref() {
|
||||
if n == '}' {
|
||||
break;
|
||||
}
|
||||
name.push(n);
|
||||
}
|
||||
} else {
|
||||
while let Some(&n) = chars.peek() {
|
||||
if n.is_alphanumeric() || n == '_' || n == '.' {
|
||||
name.push(n);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
|
||||
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
|
||||
let (key, used) = literal(rest.trim_start())?;
|
||||
(rest.trim_start()[used..].trim() == "]").then_some(key)
|
||||
});
|
||||
match properties.get(bracket.as_deref().unwrap_or(key)) {
|
||||
Some(v) if !name.is_empty() => out.push_str(v),
|
||||
_ => {
|
||||
out.push('$');
|
||||
if braced {
|
||||
out.push('{');
|
||||
}
|
||||
out.push_str(&name);
|
||||
if braced {
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
|
||||
fn starts_word(text: &str, index: usize) -> bool {
|
||||
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
|
||||
}
|
||||
|
||||
/// Identifiers outside quoted strings, with their byte positions.
|
||||
fn code_words(text: &str) -> Vec<(usize, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
|
||||
} else if c.is_alphabetic() || c == '_' {
|
||||
let (word, _) = identifier(&text[index..]);
|
||||
out.push((index, word));
|
||||
index += word.len();
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn identifier(text: &str) -> (&str, &str) {
|
||||
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
|
||||
text.split_at(end)
|
||||
}
|
||||
|
||||
fn assigned_literal(text: &str) -> Option<String> {
|
||||
let rest = text.trim_start().strip_prefix('=')?.trim_start();
|
||||
let (value, used) = literal(rest)?;
|
||||
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
|
||||
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
|
||||
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
|
||||
}
|
||||
|
||||
/// The quoted string at the start of `s`, and how many bytes it spans.
|
||||
fn literal(s: &str) -> Option<(String, usize)> {
|
||||
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
|
||||
let rest = &s[quote.len_utf8()..];
|
||||
let mut index = 0;
|
||||
while index < rest.len() {
|
||||
let c = rest[index..].chars().next()?;
|
||||
if c == quote {
|
||||
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
|
||||
}
|
||||
if c == '\\' {
|
||||
index += c.len_utf8();
|
||||
index += rest[index..].chars().next()?.len_utf8();
|
||||
} else if quote == '"' && rest[index..].starts_with("${") {
|
||||
index += 2;
|
||||
index += matching_brace(&rest[index..])? + 1;
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
|
||||
fn matching_brace(text: &str) -> Option<usize> {
|
||||
let mut depth = 1usize;
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next()?;
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..])?.1;
|
||||
continue;
|
||||
}
|
||||
if c == '{' {
|
||||
depth += 1;
|
||||
} else if c == '}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `name = 'value'` pairs in `text`.
|
||||
fn assignments(text: &str) -> Vec<(String, String)> {
|
||||
statements(text)
|
||||
.into_iter()
|
||||
.filter_map(|statement| {
|
||||
let (name, rest) = identifier(statement.trim_start());
|
||||
let value = assigned_literal(rest)?;
|
||||
(!name.is_empty()).then(|| (name.to_owned(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every settings and build script of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
|
||||
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
|
||||
// Scripts a build script applies are build files too: editing one can change the model.
|
||||
for project in &model.projects {
|
||||
let Some(file) = project.build_file.as_deref() else { continue };
|
||||
let build_dir = project_build_dir(root, model, project);
|
||||
let properties = build_dir.join("gradle.properties");
|
||||
if properties.is_file() {
|
||||
out.push(properties);
|
||||
}
|
||||
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
|
||||
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
|
||||
}
|
||||
// The settings of each nested build (its root project's folder).
|
||||
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
|
||||
let nested = root.join(&p.dir);
|
||||
@@ -982,8 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1168,7 +808,7 @@ dependencies {
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script, &BTreeMap::new());
|
||||
let deps = script_dependencies(script);
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
@@ -1182,291 +822,6 @@ dependencies {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_read_groovy_and_kotlin_forms() {
|
||||
let script = r#"
|
||||
apply plugin: 'java'
|
||||
apply from: 'gradle/dependencies.gradle'
|
||||
apply from: file('gradle/extra.gradle')
|
||||
apply(from = "gradle/kotlin.gradle.kts")
|
||||
apply(from = file("gradle/kotlin-file.gradle.kts"))
|
||||
// apply from: 'commented.gradle'
|
||||
reapply from: 'not-a-call.gradle'
|
||||
"#;
|
||||
assert_eq!(
|
||||
applied_scripts(script),
|
||||
[
|
||||
"gradle/dependencies.gradle",
|
||||
"gradle/extra.gradle",
|
||||
"gradle/kotlin.gradle.kts",
|
||||
"gradle/kotlin-file.gradle.kts"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_values_fill_in_dependency_versions() {
|
||||
let script = r#"
|
||||
ext {
|
||||
gsonVersion = '2.8.9'
|
||||
junit = "4.13.2"
|
||||
}
|
||||
ext.logback = '1.4.14'
|
||||
dependencies {
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
testImplementation "junit:junit:$junit"
|
||||
implementation "ch.qos.logback:logback-classic:${ext.logback}"
|
||||
implementation 'org.plain:literal:1.0'
|
||||
implementation "org.x:unknown:${missing}"
|
||||
}
|
||||
"#;
|
||||
let names: Vec<String> = script_dependencies(script, &script_properties(script))
|
||||
.into_iter()
|
||||
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"com.google.code.gson:gson:2.8.9",
|
||||
"junit:junit:4.13.2",
|
||||
"ch.qos.logback:logback-classic:1.4.14",
|
||||
"org.plain:literal:1.0",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_properties_support_brackets_and_kotlin_delegates() {
|
||||
for declaration in [
|
||||
"ext.x = '1.2'",
|
||||
"ext { x = '1.2' }",
|
||||
"ext[\"x\"] = '1.2'",
|
||||
"extra[\"x\"] = \"1.2\"",
|
||||
"val x by extra(\"1.2\")",
|
||||
] {
|
||||
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
|
||||
assert_eq!(
|
||||
script_dependencies(&script, &script_properties(&script)),
|
||||
[("g".into(), "a".into(), "1.2".into())],
|
||||
"{declaration}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsers_ignore_apply_and_ext_inside_strings() {
|
||||
let script = r#"
|
||||
println "apply from: 'fake.gradle'"
|
||||
println "ext.x = 'bogus'"
|
||||
ext.real = '1'
|
||||
"#;
|
||||
assert!(applied_scripts(script).is_empty());
|
||||
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
|
||||
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
|
||||
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
|
||||
assert_eq!(
|
||||
script_dependencies(script, &properties),
|
||||
[
|
||||
("g".into(), "a".into(), "1.2".into()),
|
||||
("g".into(), "b".into(), "1.2-RC1".into()),
|
||||
("g".into(), "c".into(), "2".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
|
||||
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_versions_skip_unresolved_names_and_expressions() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:missing:${unknown}"
|
||||
implementation "g:expression:${1 + 2}"
|
||||
implementation "g:cyclic:${cycle}"
|
||||
implementation 'g:range:[1,2)'
|
||||
implementation 'g:dynamic:1.+'
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
|
||||
assert!(script_dependencies(script, &properties).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_property_references_support_qualified_and_bracket_access() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:a:${ext.x}"
|
||||
implementation "g:b:${extra.x}"
|
||||
implementation "g:c:${ext['x']}"
|
||||
implementation "g:d:${extra["x"]}"
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("x".into(), "1".into())]);
|
||||
assert_eq!(script_dependencies(script, &properties).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_are_read_recursively_and_survive_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
|
||||
|
||||
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
|
||||
.into_iter()
|
||||
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
|
||||
.collect();
|
||||
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
|
||||
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_prefer_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
|
||||
std::fs::write(root.join("versions.gradle"), "right").unwrap();
|
||||
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].1, "right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_applied_paths_are_relative_to_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_fall_back_to_the_applying_script_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_resolve_gradle_directory_variables() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let project = root.join("app");
|
||||
let script = project.join("gradle/deps.gradle");
|
||||
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join("shared.gradle"), "").unwrap();
|
||||
std::fs::write(project.join("local.gradle"), "").unwrap();
|
||||
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
|
||||
Some(root.join("shared.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
for prefix in ["$projectDir", "${projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
|
||||
Some(project.join("local.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
|
||||
Some(root.join("shared.gradle"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_skip_urls_and_missing_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// Even a local path resembling a URL must not be read.
|
||||
if !cfg!(windows) {
|
||||
for scheme in ["http", "https"] {
|
||||
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
|
||||
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("build.gradle"),
|
||||
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_symlink_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_traversal_bounds_depth_and_total_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for i in 0..300 {
|
||||
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
|
||||
}
|
||||
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
|
||||
assert_eq!(chain.len(), 33);
|
||||
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
|
||||
std::fs::write(root.join("build.gradle"), fanout).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_use_their_composite_build_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("included/app")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
|
||||
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
|
||||
let mut model = load(root).unwrap();
|
||||
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
|
||||
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
|
||||
project.build = Some(":included".into());
|
||||
model.projects.push(project);
|
||||
}
|
||||
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
|
||||
let model = crate::BuildModel { maven: None, gradle: vec![model] };
|
||||
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn load_linked(
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
|
||||
|
||||
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
|
||||
/// (with the transitive ones the last sync read), except the build's own modules,
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
|
||||
/// including the scripts they pull in with `apply from`.
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
for p in &g.projects {
|
||||
let Some(build_file) = p.build_file.as_deref() else { continue };
|
||||
let build_dir = gradle::project_build_dir(root, g, p);
|
||||
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
|
||||
// The script's own `ext` values win over the scripts it applies.
|
||||
let mut properties = std::collections::BTreeMap::new();
|
||||
for (_, text) in scripts.iter().rev() {
|
||||
properties.extend(gradle::script_properties(text));
|
||||
}
|
||||
properties.extend(gradle::file_properties(&build_dir));
|
||||
for (_, text) in &scripts {
|
||||
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|
||||
|(group_id, artifact_id, version)| DependencyCoordinate {
|
||||
gradle: true,
|
||||
group_id,
|
||||
artifact_id,
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -238,30 +222,6 @@ mod tests {
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join("extra")).unwrap();
|
||||
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
|
||||
let modules = |settings: &ide_core::settings::MavenSettings| {
|
||||
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
|
||||
};
|
||||
assert!(modules(&Default::default()).is_empty());
|
||||
assert_eq!(modules(&settings), ["extra/pom.xml"]);
|
||||
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
|
||||
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
|
||||
assert!(modules(&settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -328,39 +288,4 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/dependencies.gradle"),
|
||||
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
|
||||
.collect();
|
||||
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_properties_override_script_extra_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
|
||||
std::fs::write(
|
||||
root.join("deps.gradle"),
|
||||
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
|
||||
assert_eq!(versions, ["1.2", "2.3"]);
|
||||
}
|
||||
}
|
||||
@@ -16,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"];
|
||||
@@ -81,9 +71,6 @@ pub struct MavenModel {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProject {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
|
||||
pub pom: String,
|
||||
/// Project-relative directory; `""` = the project root.
|
||||
@@ -182,9 +169,6 @@ pub enum Omission {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProfile {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
pub id: String,
|
||||
pub active_by_default: bool,
|
||||
}
|
||||
@@ -507,7 +491,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> =
|
||||
@@ -519,7 +503,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
continue;
|
||||
}
|
||||
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
@@ -527,7 +511,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 +537,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 +728,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 };
|
||||
@@ -1079,7 +1063,7 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1228,12 +1212,11 @@ pub fn load_with_linked(
|
||||
// The project can activate the profiles of its own POM and its ancestors'.
|
||||
for (id, active) in &ctx.profiles {
|
||||
if !model.profiles.iter().any(|x| x.id == *id) {
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
model.projects.push(MavenProject {
|
||||
project_root: None,
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
||||
@@ -1648,7 +1631,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));
|
||||
@@ -1853,7 +1836,7 @@ mod tests {
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
|
||||
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
|
||||
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
|
||||
|
||||
@@ -2305,24 +2288,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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -308,7 +304,7 @@ pub fn sync_maven(
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.maven_settings.profiles(&options.profiles),
|
||||
&options.profiles,
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
@@ -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();
|
||||
@@ -845,41 +818,6 @@ mod tests {
|
||||
assert!(m.stale, "gradle.properties is watched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_edits_invalidate_the_cached_gradle_model() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
|
||||
let applied = root.join("gradle/dependencies.gradle");
|
||||
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
|
||||
let model = gradle::load(&root).unwrap();
|
||||
let cache = GradleCache {
|
||||
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
|
||||
projects: vec![ReportedProject {
|
||||
path: ":".into(),
|
||||
dir: root.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
resources: true,
|
||||
..Default::default()
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
let stale = || {
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut model);
|
||||
model.stale
|
||||
};
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
|
||||
assert!(stale());
|
||||
std::fs::remove_file(&applied).unwrap();
|
||||
assert!(stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
|
||||
let online = maven_sync_goals(Path::new("e.xml"), false);
|
||||
|
||||
@@ -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 ("e, 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "ide-cli"
|
||||
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
winreg = "0.56"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Command-line arguments of `openidea`.
|
||||
|
||||
use crate::Target;
|
||||
use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
Opens folders as projects and files in the editor of the running OpenIDEA,
|
||||
starting it if needed. Without paths, brings OpenIDEA to the front.
|
||||
Lines and columns start at 1; --line and --column apply to the next path.
|
||||
|
||||
Options:
|
||||
--line <n> Put the caret on line <n> of the next file
|
||||
--column <n> Put the caret in column <n> of that line
|
||||
-h, --help Show this help
|
||||
-v, --version Show the version
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
Version,
|
||||
Open(Vec<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
parse_with(args, cwd, &|p| p.exists())
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
if args.first().is_some_and(|arg| arg == "diff") {
|
||||
return match parse_paths(&args[1..], cwd, exists)? {
|
||||
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
|
||||
Command::Help => Ok(Command::Help),
|
||||
_ => Err("diff needs two files".into()),
|
||||
};
|
||||
}
|
||||
parse_paths(args, cwd, exists)
|
||||
}
|
||||
|
||||
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut options = true;
|
||||
let (mut line, mut column) = (None, None);
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
|
||||
_ => (arg.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--" if options => options = false,
|
||||
"-h" | "--help" if options => return Ok(Command::Help),
|
||||
"-v" | "--version" if options => return Ok(Command::Version),
|
||||
"--line" | "--column" if options => {
|
||||
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
|
||||
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
|
||||
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
|
||||
}
|
||||
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
|
||||
_ => {
|
||||
let mut target = target(arg, cwd, exists)?;
|
||||
if line.is_some() || column.is_some() {
|
||||
target.line = line.take().or(target.line).or(Some(1));
|
||||
target.column = column.take();
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if line.is_some() || column.is_some() {
|
||||
return Err("--line and --column need a file after them".into());
|
||||
}
|
||||
Ok(Command::Open(targets))
|
||||
}
|
||||
|
||||
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
|
||||
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
|
||||
let path = cwd.join(arg);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: None, column: None });
|
||||
}
|
||||
for (base, line, column) in split_position(arg) {
|
||||
let path = cwd.join(base);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: Some(line), column });
|
||||
}
|
||||
}
|
||||
Err(format!("{arg}: no such file or directory"))
|
||||
}
|
||||
|
||||
/// The readings of `arg` as `path:line:column`, then as `path:line`.
|
||||
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
|
||||
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
|
||||
let Some(last) = position(last) else { return Vec::new() };
|
||||
let mut readings = Vec::new();
|
||||
if let Some((base, line)) = rest.rsplit_once(':')
|
||||
&& let Some(line) = position(line)
|
||||
&& !base.is_empty()
|
||||
{
|
||||
readings.push((base, line, Some(last)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
readings.push((rest, last, None));
|
||||
}
|
||||
readings
|
||||
}
|
||||
|
||||
fn position(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
|
||||
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
|
||||
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
|
||||
}
|
||||
|
||||
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
|
||||
Target { path: path.into(), line, column }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_cwd() {
|
||||
assert_eq!(
|
||||
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
|
||||
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_split_only_when_the_whole_arg_is_missing() {
|
||||
let files = ["/work/a.rs"];
|
||||
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
|
||||
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
|
||||
assert!(parse_in(&["a.rs:0"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs:x"], &files).is_err());
|
||||
assert!(parse_in(&["missing.rs:3"], &files).is_err());
|
||||
}
|
||||
|
||||
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_name_with_a_colon_wins_over_a_position() {
|
||||
let files = ["/work/b:2"];
|
||||
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
|
||||
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_drive_colon_is_not_a_position() {
|
||||
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
|
||||
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
|
||||
assert!(split_position(r"C:\a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
|
||||
let local = PathBuf::from(r"C:\项目\my file.rs");
|
||||
let network = PathBuf::from(r"\\server\share\folder name");
|
||||
let command = parse_with(
|
||||
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
|
||||
Path::new(r"D:\work"),
|
||||
&|path| path == local || path == network,
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
Ok(Command::Open(vec![
|
||||
Target { path: local, line: Some(12), column: Some(5) },
|
||||
Target { path: network, line: None, column: None },
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_flags_apply_to_the_next_path() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_in(&["--line=3", "a.rs:9:9"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
|
||||
);
|
||||
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
|
||||
assert!(parse_in(&["--line"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options() {
|
||||
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
|
||||
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
|
||||
assert!(parse_in(&["--nope"], &[]).is_err());
|
||||
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_diff_command_is_supported() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
|
||||
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
|
||||
assert_eq!(
|
||||
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
|
||||
//! script in a macOS bundle, or a wrapper in a user's bin directory.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMAND: &str = "openidea";
|
||||
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
|
||||
const SYSTEM_DIR: &str = "/usr/local/bin";
|
||||
|
||||
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
|
||||
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
|
||||
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
|
||||
launcher.is_file().then_some(launcher)
|
||||
}
|
||||
|
||||
/// macOS runs a quarantined app from a random read-only copy until it is moved:
|
||||
/// a link there would break on the next launch.
|
||||
pub fn translocated(path: &Path) -> bool {
|
||||
path.to_string_lossy().contains("/AppTranslocation/")
|
||||
}
|
||||
|
||||
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
|
||||
/// rights needed), else `/usr/local/bin`.
|
||||
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
|
||||
let local = home.map(|h| h.join(".local/bin"));
|
||||
match (path_var, local) {
|
||||
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
|
||||
_ => PathBuf::from(SYSTEM_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories an earlier install may have used.
|
||||
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
|
||||
}
|
||||
|
||||
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
|
||||
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
|
||||
}
|
||||
|
||||
fn is_launcher_link(link: &Path) -> bool {
|
||||
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
|
||||
}
|
||||
|
||||
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
|
||||
/// is not writable. Refuses to replace an `openidea` that is not ours.
|
||||
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
|
||||
let link = dir.join(COMMAND);
|
||||
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", link.display()),
|
||||
));
|
||||
}
|
||||
let direct = std::fs::create_dir_all(dir).and_then(|()| {
|
||||
if link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link)?;
|
||||
}
|
||||
symlink(launcher, &link)
|
||||
});
|
||||
match direct {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
||||
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Removes `links`, asking for administrator rights where needed.
|
||||
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
|
||||
for link in links {
|
||||
match std::fs::remove_file(link) {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command` as root after the system's password prompt. Cancelling it is
|
||||
/// [`io::ErrorKind::Interrupted`].
|
||||
fn privileged(command: &str) -> io::Result<()> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
|
||||
}
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
|
||||
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
// "User canceled. (-128)"
|
||||
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
|
||||
Err(io::Error::new(kind, message))
|
||||
}
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn applescript_escape(s: &str) -> String {
|
||||
s.replace('\\', r"\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
|
||||
|
||||
/// AppImages remount their executable on every launch. Keep the original image path.
|
||||
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
|
||||
let path = appimage.unwrap_or(exe);
|
||||
if !path.is_absolute() || !path.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
|
||||
dir.join(if windows { "openidea.cmd" } else { COMMAND })
|
||||
}
|
||||
|
||||
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
|
||||
let exe =
|
||||
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
|
||||
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
|
||||
}
|
||||
Ok(if windows {
|
||||
format!(
|
||||
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
|
||||
exe.replace('%', "%%")
|
||||
)
|
||||
} else {
|
||||
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
|
||||
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
|
||||
&& std::fs::read_to_string(path).is_ok_and(|text| {
|
||||
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Never follows or overwrites a foreign file, even if it has the same command name.
|
||||
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let text = wrapper(exe, windows)?;
|
||||
let path = wrapper_path(dir, windows);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", path.display()),
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
file.write_all(text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
|
||||
let path = wrapper_path(dir, windows);
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(vec![path])
|
||||
}
|
||||
|
||||
/// Preserve every unrelated PATH entry, including expandable environment variables.
|
||||
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
|
||||
fn equal(a: &str, b: &str) -> bool {
|
||||
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
|
||||
}
|
||||
let found = current.split(';').any(|entry| equal(entry, dir));
|
||||
if add {
|
||||
if found {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
|
||||
} else if found {
|
||||
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
|
||||
types::{FromRegValue, ToRegValue},
|
||||
};
|
||||
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
|
||||
let raw = match environment.get_raw_value("Path") {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
|
||||
let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) };
|
||||
let mut value = updated.to_reg_value();
|
||||
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
|
||||
environment.set_raw_value("Path", &value)?;
|
||||
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
|
||||
0,
|
||||
message.as_ptr() as isize,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Installation {
|
||||
pub path: PathBuf,
|
||||
/// Some when the user needs to add this directory to their shell's PATH.
|
||||
pub path_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
|
||||
path_var
|
||||
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
|
||||
.is_some_and(|candidate| candidate == command)
|
||||
}
|
||||
|
||||
fn user_bin() -> io::Result<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
|
||||
} else {
|
||||
dirs::home_dir().map(|dir| dir.join(".local/bin"))
|
||||
}
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
|
||||
}
|
||||
|
||||
pub fn install_current() -> io::Result<Installation> {
|
||||
let exe = std::env::current_exe()?;
|
||||
if cfg!(target_os = "macos") {
|
||||
let launcher = bundled_launcher(&exe).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
|
||||
})?;
|
||||
if translocated(&launcher) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"move OpenIDEA to the Applications folder first",
|
||||
));
|
||||
}
|
||||
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
|
||||
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
|
||||
}
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = stable_executable(&exe, image.as_deref())?;
|
||||
let dir = user_bin()?;
|
||||
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, true)?;
|
||||
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
|
||||
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
|
||||
}
|
||||
|
||||
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
|
||||
uninstall(&links)?;
|
||||
return Ok(links);
|
||||
}
|
||||
let dir = user_bin()?;
|
||||
let path = wrapper_path(&dir, cfg!(windows));
|
||||
if is_wrapper(&path, cfg!(windows)) {
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, false)?;
|
||||
return uninstall_wrapper(&dir, cfg!(windows));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_install_update_and_remove_only_their_own_files() {
|
||||
for windows in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
|
||||
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
|
||||
assert!(is_wrapper(&path, windows));
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
|
||||
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
std::fs::write(&path, "foreign command").unwrap();
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
|
||||
let dir = r"C:\Users\用户\OpenIDEA\bin";
|
||||
let initial = r"%USERPROFILE%\tools;C:\other";
|
||||
let updated = windows_path(initial, dir, true).unwrap();
|
||||
assert_eq!(updated, format!("{dir};{initial}"));
|
||||
assert_eq!(windows_path(&updated, dir, true), None);
|
||||
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
|
||||
assert_eq!(
|
||||
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
|
||||
Some(r"C:\other")
|
||||
);
|
||||
assert_eq!(windows_path(initial, dir, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("OpenIDEA.AppImage");
|
||||
std::fs::write(&image, "image").unwrap();
|
||||
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
|
||||
assert_eq!(stable_executable(&image, None).unwrap(), image);
|
||||
assert!(stable_executable(Path::new("missing"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let system = dir.path().join("system");
|
||||
let user = dir.path().join("user");
|
||||
for bin in [&system, &user] {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(bin.join(COMMAND), "command").unwrap();
|
||||
}
|
||||
let path = std::env::join_paths([&system, &user]).unwrap();
|
||||
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
let path = std::env::join_paths([&user, &system]).unwrap();
|
||||
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
|
||||
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
|
||||
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cmd_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("程序 & 100% !special!.exe");
|
||||
let source = dir.path().join("helper.rs");
|
||||
std::fs::write(
|
||||
&source,
|
||||
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let compiled = std::process::Command::new("rustc")
|
||||
.args(["--crate-name", "launcher_test"])
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
|
||||
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
|
||||
let check = dir.path().join("check.cmd");
|
||||
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\"\"", check.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let app = root.join("OpenIDEA.app");
|
||||
let exe = app.join("Contents/MacOS/openidea");
|
||||
let launcher = app.join(LAUNCHER);
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
(exe, launcher)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_launcher_of_a_bundle_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (exe, launcher) = bundle(dir.path());
|
||||
assert_eq!(bundled_launcher(&exe), Some(launcher));
|
||||
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
|
||||
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_local_bin_on_path() {
|
||||
let home = Path::new("/Users/u");
|
||||
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
|
||||
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
|
||||
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_replaces_its_own_link_but_not_other_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_, launcher) = bundle(dir.path());
|
||||
let bin = dir.path().join("bin");
|
||||
let link = install(&launcher, &bin).unwrap();
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
|
||||
assert_eq!(install(&launcher, &bin).unwrap(), link);
|
||||
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
|
||||
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
|
||||
assert!(installed(std::slice::from_ref(&bin)).is_empty());
|
||||
std::fs::write(&link, "someone else's").unwrap();
|
||||
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
|
||||
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! The `openidea` command.
|
||||
//!
|
||||
//! The command is the app's own executable run in CLI mode: it parses its
|
||||
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
|
||||
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
|
||||
//! script into a directory on `PATH`.
|
||||
|
||||
pub mod args;
|
||||
pub mod install;
|
||||
pub mod socket;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`Request`] changes incompatibly; an app still running an older
|
||||
/// version answers with an error instead of misreading it.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// A file or folder to open, with an optional 1-based position in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Target {
|
||||
/// Absolute.
|
||||
pub path: PathBuf,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the command asks the running app to do. No targets: bring the app to the front.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> Self {
|
||||
Self { version: PROTOCOL_VERSION, targets, diff: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Response {
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
|
||||
/// console for interactive help and errors. Batch launchers wait for this process.
|
||||
#[cfg(windows)]
|
||||
pub fn attach_console() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
||||
SetStdHandle,
|
||||
};
|
||||
// SAFETY: attaches only this CLI process, without allocating a new console.
|
||||
unsafe {
|
||||
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
|
||||
let handle = GetStdHandle(kind);
|
||||
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
|
||||
});
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
for (kind, handle, keep) in redirected {
|
||||
if keep {
|
||||
SetStdHandle(kind, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
//! The local socket the running app listens on: one JSON [`Request`] line in,
|
||||
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
|
||||
|
||||
use crate::{Request, Response};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_MESSAGE: u64 = 1024 * 1024;
|
||||
|
||||
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
|
||||
handle(request)
|
||||
}
|
||||
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
|
||||
Err(e) => Response::Error { message: format!("invalid request: {e}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
|
||||
/// socket paths are limited to about 100 bytes, and development identifiers are long.
|
||||
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let user = dirs::data_local_dir()?;
|
||||
return Some(PathBuf::from(format!(
|
||||
r"\\.\pipe\openidea-{:016x}",
|
||||
fnv1a(&format!("{}:{identifier}", user.display()))
|
||||
)));
|
||||
}
|
||||
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
|
||||
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
|
||||
}
|
||||
|
||||
fn fnv1a(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
write_line(&stream, request)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let listener = bind(socket)?;
|
||||
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = serve_one(&stream, &handle) {
|
||||
tracing::warn!("cli request failed: {e}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binds `socket`, replacing a stale file no one answers on.
|
||||
fn bind(socket: &Path) -> io::Result<UnixListener> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
|
||||
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::remove_file(socket)?;
|
||||
UnixListener::bind(socket)?
|
||||
}
|
||||
bound => bound?,
|
||||
};
|
||||
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
|
||||
write_line(stream, &answer(&line, handle))
|
||||
}
|
||||
|
||||
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
|
||||
let mut line = serde_json::to_string(value)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
|
||||
|
||||
fn runtime() -> io::Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
runtime()?.block_on(async {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
let mut pipe = loop {
|
||||
match ClientOptions::new().open(socket) {
|
||||
Ok(pipe) => break pipe,
|
||||
// All existing instances are busy; wait instead of starting another app.
|
||||
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
let mut line = serde_json::to_vec(request)?;
|
||||
line.push(b'\n');
|
||||
pipe.write_all(&line).await?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
|
||||
pipe.write_all(b"\n").await?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let socket = socket.to_owned();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
|
||||
let runtime = match runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
if let Err(e) = pipe.connect().await {
|
||||
tracing::warn!("CLI pipe connection failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Keep an instance bound while answering, so another process cannot take the name.
|
||||
let next = match ServerOptions::new().create(&socket) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot create CLI pipe: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reply = tokio::time::timeout(TIMEOUT, async {
|
||||
let mut line = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
|
||||
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
|
||||
reply.push(b'\n');
|
||||
pipe.write_all(&reply).await?;
|
||||
// Wait until the client has drained the reply before closing its instance.
|
||||
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
if !matches!(reply, Ok(Ok(()))) {
|
||||
tracing::warn!("CLI pipe request failed: {reply:?}");
|
||||
}
|
||||
// Dropping after the write lets the client drain its reply before disconnecting.
|
||||
drop(pipe);
|
||||
pipe = next;
|
||||
}
|
||||
});
|
||||
})?;
|
||||
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the app listening on `socket` and waits for its answer.
|
||||
pub use imp::send;
|
||||
/// Answers requests on `socket` with `handle`, on a thread of its own.
|
||||
pub use imp::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_names_separate_identifiers_and_stay_short() {
|
||||
let release = socket_path("ai.l0ng.openidea").unwrap();
|
||||
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
|
||||
assert_ne!(release, dev);
|
||||
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_only_its_protocol_version() {
|
||||
let ok = |_: Request| Response::Ok;
|
||||
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
|
||||
let old = r#"{"version":0,"targets":[]}"#;
|
||||
assert!(matches!(answer(old, &ok), Response::Error { .. }));
|
||||
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn named_pipe_round_trip() {
|
||||
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
|
||||
}
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn request_round_trip_and_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
// A file left by an app that exited without removing it.
|
||||
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv().unwrap().targets, vec![target]);
|
||||
// A live server keeps its socket.
|
||||
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_remove_an_unrelated_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
std::fs::write(&socket, "keep").unwrap();
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,3 @@ license.workspace = true
|
||||
objc2 = { version = "0.6", default-features = false, features = ["std"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
@@ -21,12 +21,6 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
|
||||
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -171,12 +165,8 @@ mod imp {
|
||||
/// Opens the clipboard as its owner: the list is written in full, so no
|
||||
/// owning window is needed.
|
||||
fn open() -> bool {
|
||||
open_for(ptr::null_mut())
|
||||
}
|
||||
|
||||
fn open_for(owner: Handle) -> bool {
|
||||
for _ in 0..OPEN_ATTEMPTS {
|
||||
if unsafe { OpenClipboard(owner) } != 0 {
|
||||
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(OPEN_RETRY);
|
||||
@@ -240,29 +230,6 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
let (memory, _) = global_text(text);
|
||||
if memory.is_null() {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
|
||||
unsafe {
|
||||
if !open_for(owner as Handle) {
|
||||
GlobalFree(memory);
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
|
||||
let error = std::io::Error::last_os_error();
|
||||
CloseClipboard();
|
||||
if taken {
|
||||
Ok(())
|
||||
} else {
|
||||
GlobalFree(memory);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
let bytes = encode(paths);
|
||||
if bytes.len() <= HEADER {
|
||||
@@ -373,19 +340,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
board.clearContents();
|
||||
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other("Cannot write clipboard text"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_files(paths: &[PathBuf]) -> bool {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
@@ -416,19 +370,6 @@ mod imp {
|
||||
mod imp {
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).set_text(text);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = text;
|
||||
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_files() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
@@ -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,
|
||||
@@ -169,16 +166,6 @@ impl Default for CompletionSettings {
|
||||
pub struct AutoImportSettings {
|
||||
pub add_unambiguous: bool,
|
||||
pub optimize_on_the_fly: bool,
|
||||
pub imports_on_paste: ImportsOnPaste,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImportsOnPaste {
|
||||
#[default]
|
||||
Ask,
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Editor › General › Code Folding.
|
||||
@@ -264,7 +251,6 @@ pub struct SmartKeysSettings {
|
||||
pub tab_out_of_pair: bool,
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub escape_on_paste: bool,
|
||||
pub smart_indent: bool,
|
||||
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
|
||||
pub insert_doc_stub: bool,
|
||||
@@ -282,7 +268,6 @@ impl Default for SmartKeysSettings {
|
||||
surround_selection: true,
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
escape_on_paste: true,
|
||||
smart_indent: true,
|
||||
insert_doc_stub: true,
|
||||
backspace_unindent: "autoindent".into(),
|
||||
@@ -333,23 +318,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 +329,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,24 +464,3 @@ impl Default for EncodingSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod auto_import_tests {
|
||||
use super::{AutoImportSettings, ImportsOnPaste};
|
||||
|
||||
#[test]
|
||||
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
|
||||
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
|
||||
assert!(settings.add_unambiguous);
|
||||
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_on_paste_modes_round_trip() {
|
||||
for mode in ["ask", "always", "never"] {
|
||||
let json = serde_json::json!({ "importsOnPaste": mode });
|
||||
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,29 +10,11 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project data source settings changed; reload the Database tree.
|
||||
DbSourcesChanged,
|
||||
McpRequest {
|
||||
id: u32,
|
||||
request: McpUiRequest,
|
||||
},
|
||||
McpRequestClosed {
|
||||
id: u32,
|
||||
},
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
/// A window entered or left full screen, itself or as a member of a full-screen window group.
|
||||
WindowFullscreenChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
@@ -42,10 +24,6 @@ pub enum IdeEvent {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
ignore_rules: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
@@ -152,12 +130,6 @@ pub enum IdeEvent {
|
||||
SettingsChanged {
|
||||
key: String,
|
||||
},
|
||||
UpdateStatusChanged {
|
||||
status: crate::updates::UpdateStatus,
|
||||
},
|
||||
UpdateAvailable {
|
||||
update: crate::updates::UpdateInfo,
|
||||
},
|
||||
/// The project's source / test / resource / excluded roots changed (build files
|
||||
/// changed or folders were marked); the UI re-reads them with `project_roots`.
|
||||
ProjectRootsChanged {
|
||||
@@ -273,27 +245,6 @@ pub enum DebugEvent {
|
||||
change_type: String,
|
||||
message: String,
|
||||
},
|
||||
/// The session's capabilities changed after it started: fetch them again.
|
||||
Capabilities,
|
||||
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`.
|
||||
@@ -315,19 +266,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(°raded).unwrap(),
|
||||
json!({"kind": "jvmBackend", "state": {"kind": "degraded", "detail": "x"}})
|
||||
);
|
||||
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Go workspace module paths shared by project builds and run detection.
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
|
||||
pub fn work_modules(text: &str) -> Vec<PathBuf> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\n' | '(' | ')' => tokens.push(ch.to_string()),
|
||||
ch if ch.is_whitespace() => {}
|
||||
'/' if chars.peek() == Some(&'/') => {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
tokens.push("\n".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' | '`' => {
|
||||
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
|
||||
tokens.push(token);
|
||||
}
|
||||
_ => {
|
||||
let mut token = ch.to_string();
|
||||
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
|
||||
token.push(chars.next().unwrap());
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut start = true;
|
||||
let mut tokens = tokens.into_iter();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "\n" {
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
if start
|
||||
&& token == "use"
|
||||
&& let Some(path) = tokens.next()
|
||||
{
|
||||
if path == "(" {
|
||||
let mut paths = Vec::new();
|
||||
let mut closed = false;
|
||||
for path in tokens.by_ref() {
|
||||
if path == ")" {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if path != "\n" && !path.is_empty() {
|
||||
paths.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
out.extend(paths);
|
||||
}
|
||||
} else if path != "\n" && !path.is_empty() {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|path| seen.insert(path.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == quote {
|
||||
return String::from_utf8(bytes).ok();
|
||||
}
|
||||
if ch == '\\' && quote == '"' {
|
||||
let escaped = chars.next()?;
|
||||
let byte = match escaped {
|
||||
'a' => 7,
|
||||
'b' => 8,
|
||||
'f' => 12,
|
||||
'n' => 10,
|
||||
'r' => 13,
|
||||
't' => 9,
|
||||
'v' => 11,
|
||||
'\\' => b'\\',
|
||||
'"' => b'"',
|
||||
'x' => {
|
||||
let digits: String = chars.by_ref().take(2).collect();
|
||||
if digits.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 16).ok()?
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let width = if escaped == 'u' { 4 } else { 8 };
|
||||
let digits: String = chars.by_ref().take(width).collect();
|
||||
if digits.len() != width {
|
||||
return None;
|
||||
}
|
||||
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
continue;
|
||||
}
|
||||
'0'..='7' => {
|
||||
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
|
||||
if digits.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 8).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bytes.push(byte);
|
||||
} else {
|
||||
if ch == '\n' && quote == '"' {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
//! Bounded, ordered process input. Only the worker touches the OS writer.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
bytes: usize,
|
||||
finished: bool,
|
||||
aborted: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
|
||||
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
|
||||
/// without joining the worker; `finish` instead drains accepted input before EOF.
|
||||
pub struct InputWriter {
|
||||
shared: Arc<(Mutex<State>, Condvar)>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new().name(name).spawn(move || {
|
||||
let (lock, wake) = &*worker_shared;
|
||||
let result = (|| {
|
||||
loop {
|
||||
let data = {
|
||||
let mut state = lock.lock().unwrap();
|
||||
while state.queue.is_empty() && !state.finished && !state.aborted {
|
||||
state = wake.wait(state).unwrap();
|
||||
}
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(data) = state.queue.pop_front() else { return Ok(()) };
|
||||
data
|
||||
};
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if lock.lock().unwrap().aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let flushing = offset == data.len();
|
||||
let result = if !flushing {
|
||||
writer.write(&data[offset..]).and_then(|n| {
|
||||
if n == 0 {
|
||||
Err(io::ErrorKind::WriteZero.into())
|
||||
} else {
|
||||
offset += n;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
writer.flush()
|
||||
};
|
||||
match result {
|
||||
Ok(()) if flushing => break,
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let len = data.len();
|
||||
drop(data);
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.bytes = state.bytes.saturating_sub(len);
|
||||
}
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let mut state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return;
|
||||
}
|
||||
state.error = Some(e.to_string());
|
||||
state.queue.clear();
|
||||
state.bytes = 0;
|
||||
drop(state);
|
||||
on_error(e);
|
||||
}
|
||||
})?;
|
||||
Ok(Self { shared, worker: Some(worker) })
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> io::Result<()> {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
if let Some(error) = &state.error {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
|
||||
}
|
||||
if state.finished || state.aborted {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
|
||||
));
|
||||
}
|
||||
state.bytes += data.len();
|
||||
state.queue.push_back(data.to_vec());
|
||||
wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send EOF after the accepted writes, without waiting for the child to read.
|
||||
pub fn finish(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
lock.lock().unwrap().finished = true;
|
||||
wake.notify_one();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.aborted = true;
|
||||
state.queue.clear();
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputWriter {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
let Some(worker) = self.worker.take() else { return };
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
// A cancellation can race with entry into WriteFile, so retry off
|
||||
// the caller's thread until the worker observes the closed queue.
|
||||
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
|
||||
while !worker.is_finished() {
|
||||
unsafe {
|
||||
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
|
||||
}
|
||||
std::thread::sleep(RETRY);
|
||||
}
|
||||
let _ = worker.join();
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
drop(worker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowWriter {
|
||||
ready: Arc<AtomicBool>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
dropped: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl Write for SlowWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if !self.ready.load(Ordering::Acquire) {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let n = bytes.len().min(7);
|
||||
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SlowWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.dropped.send(());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
std::thread::sleep(RETRY * 2);
|
||||
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
writer.close();
|
||||
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
|
||||
assert!(output.lock().unwrap().is_empty());
|
||||
assert!(writer.write(b"closed").is_err());
|
||||
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let paste = "abé".repeat(1024);
|
||||
writer.write(paste.as_bytes()).unwrap();
|
||||
writer.write(b"tail").unwrap();
|
||||
writer.finish();
|
||||
assert!(writer.write(b"after EOF").is_err());
|
||||
ready.store(true, Ordering::Release);
|
||||
rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let writer = InputWriter { shared, worker: None };
|
||||
for _ in 0..MAX_WRITES {
|
||||
writer.write(b"x").unwrap();
|
||||
}
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
let started = Instant::now();
|
||||
writer.close();
|
||||
writer.worker.take().unwrap().join().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,13 @@
|
||||
pub mod editor_settings;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod input;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
@@ -26,7 +23,6 @@ pub mod settings;
|
||||
pub mod task;
|
||||
pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod updates;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
@@ -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,23 +104,12 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows_job::Job;
|
||||
#[cfg(windows)]
|
||||
pub use windows_tree::ProcessTree;
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
@@ -210,48 +130,13 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -262,7 +147,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -302,12 +187,8 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
@@ -322,167 +203,6 @@ mod windows_tree {
|
||||
}
|
||||
}
|
||||
|
||||
/// A kernel job object over one launched process tree. Everything a launch starts joins
|
||||
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
|
||||
/// started after the launch's snapshot was taken, orphans of a launcher that already
|
||||
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
|
||||
/// processes born in the window a process-enumeration kill would miss. Spawns assign
|
||||
/// their root right after it starts; when the assignment fails (an enclosing job that
|
||||
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
|
||||
#[cfg(windows)]
|
||||
mod windows_job {
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
|
||||
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A job object holding one launch's processes. The handle is kept open for the
|
||||
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
|
||||
/// launch record (a run entry, a debug session) is finally dropped.
|
||||
pub struct Job(OwnedHandle);
|
||||
|
||||
impl Job {
|
||||
/// Takes the just-started `child` into a new job, so everything it starts is in
|
||||
/// there too. `None` when the job could not be made or the process rejected the
|
||||
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
|
||||
pub fn for_child(child: &std::process::Child) -> Option<Job> {
|
||||
// SAFETY: the child's own handle is a live process handle.
|
||||
Self::from_handle(child.as_raw_handle())
|
||||
}
|
||||
|
||||
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
|
||||
/// a `tokio` child). `None` when the id no longer names an openable process — a
|
||||
/// caller that holds the child cannot have had it reaped, so this is a dead or
|
||||
/// already-gone launch.
|
||||
pub fn for_pid(pid: u32) -> Option<Job> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the handle was just opened and is owned here.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
Self::from_handle(handle.as_raw_handle())
|
||||
}
|
||||
|
||||
fn from_handle(handle: RawHandle) -> Option<Job> {
|
||||
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
|
||||
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
||||
if job.is_null() || job == INVALID_HANDLE_VALUE {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let job = unsafe { OwnedHandle::from_raw_handle(job) };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||
PerProcessUserTimeLimit: 0,
|
||||
PerJobUserTimeLimit: 0,
|
||||
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
MinimumWorkingSetSize: 0,
|
||||
MaximumWorkingSetSize: 0,
|
||||
ActiveProcessLimit: 0,
|
||||
Affinity: 0,
|
||||
PriorityClass: 0,
|
||||
SchedulingClass: 0,
|
||||
},
|
||||
IoInfo: IO_COUNTERS {
|
||||
ReadOperationCount: 0,
|
||||
WriteOperationCount: 0,
|
||||
OtherOperationCount: 0,
|
||||
ReadTransferCount: 0,
|
||||
WriteTransferCount: 0,
|
||||
OtherTransferCount: 0,
|
||||
},
|
||||
ProcessMemoryLimit: 0,
|
||||
JobMemoryLimit: 0,
|
||||
PeakProcessMemoryUsed: 0,
|
||||
PeakJobMemoryUsed: 0,
|
||||
};
|
||||
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
SetInformationJobObject(
|
||||
job.as_raw_handle(),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&mut limits as *mut _ as *const _,
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: the process handle belongs to the just-spawned child and outlives
|
||||
// this call; the job handle is owned here.
|
||||
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
|
||||
if ok == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Job(job))
|
||||
}
|
||||
|
||||
/// Adds another just-started root of the same launch to the job (a Go build's
|
||||
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
|
||||
/// take; the process then relies on its own kill.
|
||||
pub fn assign_pid(&self, pid: u32) -> bool {
|
||||
// SAFETY: plain call; the handle is opened only to assign it, and the job
|
||||
// handle is owned and valid.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: both handles are valid for the duration of the call.
|
||||
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
|
||||
// SAFETY: the process handle was opened above and is owned here.
|
||||
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
|
||||
ok != 0
|
||||
}
|
||||
|
||||
/// Terminates every process in the job. `false` when the call failed (the job
|
||||
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
|
||||
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
|
||||
/// the tree is gone.
|
||||
pub fn terminate(&self) -> bool {
|
||||
// SAFETY: a job handle owned here.
|
||||
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
|
||||
}
|
||||
|
||||
/// Whether any process of the launch is still in the job. When the job's state
|
||||
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
|
||||
/// [`Job::terminate`] does not depend on the answer.
|
||||
pub fn is_running(&self) -> bool {
|
||||
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
|
||||
// longer, which means there are processes.
|
||||
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
|
||||
NumberOfAssignedProcesses: 0,
|
||||
NumberOfProcessIdsInList: 0,
|
||||
ProcessIdList: [0],
|
||||
};
|
||||
let mut returned = 0u32;
|
||||
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
|
||||
let ok = unsafe {
|
||||
QueryInformationJobObject(
|
||||
self.0.as_raw_handle(),
|
||||
JobObjectBasicProcessIdList,
|
||||
&mut list as *mut _ as *mut _,
|
||||
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
|
||||
&mut returned,
|
||||
)
|
||||
};
|
||||
if ok != 0 {
|
||||
list.NumberOfProcessIdsInList > 0
|
||||
} else {
|
||||
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -579,11 +299,9 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
|
||||
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
|
||||
|
||||
/// What to change in `vars` (an environment) so a child sees the host's environment rather
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
|
||||
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
|
||||
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
|
||||
/// variables left empty are removed, and so are the runtime's own launch variables.
|
||||
/// Nothing changes when `APPDIR` is not set.
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
|
||||
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
|
||||
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
|
||||
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
|
||||
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
|
||||
let Some(appdir) =
|
||||
@@ -591,12 +309,7 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let inside = |entry: &str| {
|
||||
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
|
||||
// another image's (e.g. the pre-update one a restart inherited), never the host's.
|
||||
own || entry.starts_with("/tmp/.mount_")
|
||||
};
|
||||
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
let mut fixes = Vec::new();
|
||||
for (key, value) in vars {
|
||||
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
|
||||
@@ -799,30 +512,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() {
|
||||
@@ -1026,7 +715,7 @@ mod tests {
|
||||
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
|
||||
("HOME", "/home/me"),
|
||||
("JAVA_HOME", "/opt/jdk"),
|
||||
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -1039,27 +728,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_of_a_previous_updates_mount_are_dropped() {
|
||||
// An in-app update restart inherits the old mount's entries: a child's curl
|
||||
// must not load the old image's libraries (they can still be mounted).
|
||||
let got = fixes(&[
|
||||
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
|
||||
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
|
||||
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
fix("APPDIR", None),
|
||||
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
|
||||
fix("PATH", Some("/usr/bin")),
|
||||
fix("GIO_MODULE_DIR", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_left_empty_are_removed() {
|
||||
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
|
||||
@@ -1143,25 +811,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 +880,3 @@ mod kill_tests {
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod job_tests {
|
||||
use super::*;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::time::Instant;
|
||||
|
||||
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
|
||||
/// application: the job must reach both.
|
||||
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let child = cmd.spawn().unwrap();
|
||||
let job = Job::for_child(&child).expect("the job takes the launch");
|
||||
let tree = ProcessTree::capture(child.id());
|
||||
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
|
||||
(child, job, tree)
|
||||
}
|
||||
|
||||
/// Termination is asynchronous: the job reports its processes gone while their
|
||||
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
|
||||
fn assert_gone(tree: &ProcessTree) {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
while tree.is_running() {
|
||||
assert!(Instant::now() < until, "the tree survived the job");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_terminates_the_tree_the_root_started() {
|
||||
let (mut child, job, tree) = launch_tree();
|
||||
assert!(job.is_running());
|
||||
assert!(job.terminate(), "TerminateJobObject succeeds");
|
||||
// The captured tree's handles survive the root's death, so the wait below is the
|
||||
// grandchild's, not a reused pid's.
|
||||
assert_gone(&tree);
|
||||
assert!(!job.is_running());
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_spawned_root_joins_the_job() {
|
||||
let (mut first, job, _) = launch_tree();
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
|
||||
let mut second = cmd.spawn().unwrap();
|
||||
let second_tree = ProcessTree::capture(second.id());
|
||||
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
|
||||
assert!(job.terminate());
|
||||
assert_gone(&second_tree);
|
||||
let _ = first.wait();
|
||||
let _ = second.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_pid_makes_no_job() {
|
||||
// An id of a process this test cannot open (or that does not exist) gives no job,
|
||||
// which is the callers' cue for the `taskkill /T /F` fallback.
|
||||
assert!(Job::for_pid(u32::MAX).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Shared filesystem rules for discovering builds and resolving their owners.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const SEARCH_DEPTH: usize = 3;
|
||||
@@ -36,23 +35,10 @@ pub fn discover_matching(
|
||||
max_depth: usize,
|
||||
stop_at_match: bool,
|
||||
marked: impl Fn(&Path) -> bool,
|
||||
) -> Vec<PathBuf> {
|
||||
discover_matching_cancellable(root, max_depth, stop_at_match, &crate::CancellationToken::new(), marked)
|
||||
}
|
||||
|
||||
pub fn discover_matching_cancellable(
|
||||
root: &Path,
|
||||
max_depth: usize,
|
||||
stop_at_match: bool,
|
||||
cancel: &crate::CancellationToken,
|
||||
marked: impl Fn(&Path) -> bool,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
let mut pending = vec![(root.to_path_buf(), 0)];
|
||||
while let Some((dir, depth)) = pending.pop() {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
if marked(&dir) {
|
||||
found.push(dir.clone());
|
||||
if stop_at_match {
|
||||
@@ -80,70 +66,6 @@ pub fn discover_matching_cancellable(
|
||||
found
|
||||
}
|
||||
|
||||
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
|
||||
pub fn expand_members(root: &Path, patterns: &[String], marker: &str) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
for pattern in patterns {
|
||||
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('/');
|
||||
if pattern.is_empty() || pattern.starts_with('!') {
|
||||
continue;
|
||||
}
|
||||
let base = pattern.strip_suffix("/**").or_else(|| pattern.strip_suffix("/*"));
|
||||
match base {
|
||||
Some(base) if !base.contains('*') => {
|
||||
dirs.extend(children(&root.join(base)).into_iter().filter(|d| d.join(marker).is_file()))
|
||||
}
|
||||
None if !pattern.contains('*') => {
|
||||
let dir = root.join(pattern);
|
||||
if dir.join(marker).is_file() {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
dirs.retain(|d| seen.insert(d.clone()));
|
||||
dirs
|
||||
}
|
||||
|
||||
pub fn node_workspace_members(root: &Path) -> Vec<PathBuf> {
|
||||
let package = std::fs::read(root.join("package.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
expand_members(root, &node_workspace_patterns(root, &package), "package.json")
|
||||
}
|
||||
|
||||
fn node_workspace_patterns(root: &Path, pkg: &serde_json::Value) -> Vec<String> {
|
||||
let mut patterns: Vec<String> = Vec::new();
|
||||
let ws = pkg.get("workspaces");
|
||||
let list = ws
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.or_else(|| ws.and_then(|w| w.get("packages")).and_then(serde_json::Value::as_array));
|
||||
if let Some(list) = list {
|
||||
patterns.extend(list.iter().filter_map(serde_json::Value::as_str).map(str::to_owned));
|
||||
}
|
||||
if let Ok(yaml) = std::fs::read_to_string(root.join("pnpm-workspace.yaml")) {
|
||||
let mut in_packages = false;
|
||||
for line in yaml.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if !line.starts_with([' ', '\t', '-']) {
|
||||
in_packages = trimmed.starts_with("packages:");
|
||||
continue;
|
||||
}
|
||||
if in_packages && let Some(item) = trimmed.strip_prefix('-') {
|
||||
let item = item.split(" #").next().unwrap_or_default().trim().trim_matches(['"', '\'']);
|
||||
patterns.push(item.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
patterns
|
||||
}
|
||||
|
||||
/// The closest package manager declaration or lockfile, shared by build and run.
|
||||
pub fn package_manager(root: &Path) -> &'static str {
|
||||
for dir in root.ancestors() {
|
||||
|
||||
@@ -191,30 +191,6 @@ pub struct RunTarget {
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
/// A plugin-owned argv wrapper for project runs (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<PluginCommandOverride>,
|
||||
}
|
||||
|
||||
/// Per-command argv prefix and optional replacement working-directory arguments.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommandOverride {
|
||||
pub name: String,
|
||||
pub args: Vec<String>,
|
||||
/// None inherits the target's list; Some([]) suppresses it.
|
||||
#[serde(default)]
|
||||
pub workdir_args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -59,18 +59,14 @@ 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).
|
||||
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,192 +78,14 @@ 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.
|
||||
#[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.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -412,8 +230,6 @@ impl Default for Settings {
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
mcp_server: McpServerSettings::default(),
|
||||
agent_security: AgentSecuritySettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -439,10 +255,6 @@ pub struct CustomKeymap {
|
||||
pub struct GeneralSettings {
|
||||
pub reopen_last_project: bool,
|
||||
pub confirm_exit: bool,
|
||||
/// When on, the last window closes instead of showing the Welcome screen.
|
||||
pub close_without_welcome: bool,
|
||||
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
|
||||
pub close_tabs_with_window: bool,
|
||||
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
|
||||
pub open_project_in: String,
|
||||
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
|
||||
@@ -458,11 +270,6 @@ pub struct GeneralSettings {
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
pub auto_check_updates: bool,
|
||||
/// 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 +277,6 @@ impl Default for GeneralSettings {
|
||||
Self {
|
||||
reopen_last_project: true,
|
||||
confirm_exit: true,
|
||||
close_without_welcome: false,
|
||||
close_tabs_with_window: false,
|
||||
open_project_in: "ask".into(),
|
||||
default_project_directory: String::new(),
|
||||
save_on_idle: false,
|
||||
@@ -480,9 +285,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 +339,6 @@ impl Default for NotificationGroupSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
|
||||
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NavigationBarPosition {
|
||||
/// Above the editor, below the main menu / toolbar.
|
||||
Top,
|
||||
/// In the footer, next to the status bar widgets — the default, matching
|
||||
/// where the bar lived before the three-state choice existed.
|
||||
#[default]
|
||||
StatusBar,
|
||||
/// Not shown at all.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
@@ -586,10 +373,8 @@ 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,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -597,12 +382,6 @@ pub struct AppearanceSettings {
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
|
||||
pub show_main_toolbar: bool,
|
||||
pub show_status_bar: bool,
|
||||
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
|
||||
/// the status bar by default, above the editor, or not at all.
|
||||
pub navigation_bar: NavigationBarPosition,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
@@ -666,16 +445,12 @@ impl Default for AppearanceSettings {
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
main_menu_alt: true,
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
navigation_bar: NavigationBarPosition::StatusBar,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -713,9 +488,6 @@ pub struct EditorSettings {
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
|
||||
pub semantic_highlighting: bool,
|
||||
/// Docstring markup format whose tags and keywords the Python editor highlights
|
||||
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
|
||||
pub docstring_format: String,
|
||||
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
|
||||
pub rainbow_brackets: bool,
|
||||
pub ligatures: bool,
|
||||
@@ -760,7 +532,6 @@ impl Default for EditorSettings {
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
docstring_format: "rest".into(),
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
fallback_font: String::new(),
|
||||
@@ -805,15 +576,6 @@ pub struct FileSettings {
|
||||
pub file_types: BTreeMap<String, FileTypeAssociation>,
|
||||
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
|
||||
pub file_type_overrides: BTreeMap<String, String>,
|
||||
/// Compose detection choices, keyed by project-relative file path.
|
||||
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ComposeFileOverride {
|
||||
Kept,
|
||||
Excluded,
|
||||
}
|
||||
|
||||
/// One language's file name and hashbang patterns on top of its definition.
|
||||
@@ -841,7 +603,6 @@ impl Default for FileSettings {
|
||||
local_history_max_mb: 512,
|
||||
file_types: BTreeMap::new(),
|
||||
file_type_overrides: BTreeMap::new(),
|
||||
compose_file_overrides: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,8 +610,6 @@ impl Default for FileSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
|
||||
pub plugin_formatters: BTreeMap<String, String>,
|
||||
pub python: PythonSettings,
|
||||
pub node: NodeSettings,
|
||||
pub php: PhpSettings,
|
||||
@@ -863,13 +622,11 @@ pub struct LanguagesSettings {
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
pub arduino: ArduinoSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_formatters: BTreeMap::new(),
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
@@ -885,22 +642,10 @@ impl Default for LanguagesSettings {
|
||||
java: JavaSettings::default(),
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
arduino: ArduinoSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArduinoSettings {
|
||||
pub cli: String,
|
||||
pub clangd: String,
|
||||
pub language_server: String,
|
||||
pub cli_config: String,
|
||||
pub fqbn: String,
|
||||
}
|
||||
|
||||
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -1003,8 +748,6 @@ impl Default for PythonSettings {
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
@@ -1032,8 +775,6 @@ pub struct RubySettings {
|
||||
pub struct NodeSettings {
|
||||
/// `node` executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
}
|
||||
@@ -1109,8 +850,6 @@ pub struct JavaSettings {
|
||||
/// Project JDK home; empty = per module, the installed JDK matching the
|
||||
/// Java version it targets.
|
||||
pub jdk: String,
|
||||
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
|
||||
pub language_level: String,
|
||||
/// JDK home the Java language server (jdtls) runs on, apart from the project
|
||||
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
|
||||
/// else the first installed one that is.
|
||||
@@ -1118,8 +857,6 @@ pub struct JavaSettings {
|
||||
/// Extra JVM options for the Java language server (jdtls), space-separated
|
||||
/// (VS Code's `java.jdt.ls.vmargs`).
|
||||
pub jdtls_vm_args: String,
|
||||
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
|
||||
pub jdtls_shared: bool,
|
||||
/// Build Tools › Maven.
|
||||
pub maven: MavenSettings,
|
||||
/// Build Tools › Gradle.
|
||||
@@ -1166,8 +903,6 @@ pub enum GradleDistribution {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MavenSettings {
|
||||
/// JDK home used to run Maven; empty = Project JDK, else the POM's Java version.
|
||||
pub runner_jre: String,
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
@@ -1176,8 +911,6 @@ pub struct MavenSettings {
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: String,
|
||||
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
|
||||
pub toolchains: String,
|
||||
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
|
||||
pub local_repository: String,
|
||||
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
|
||||
@@ -1189,25 +922,9 @@ pub struct MavenSettings {
|
||||
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
|
||||
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
/// Settings profiles followed by the Maven tool window's choices.
|
||||
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
|
||||
let mut profiles = Vec::new();
|
||||
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
|
||||
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
|
||||
profiles.push(profile.to_owned());
|
||||
}
|
||||
}
|
||||
let excluded: Vec<String> =
|
||||
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
|
||||
profiles.retain(|p| !excluded.contains(p));
|
||||
profiles
|
||||
}
|
||||
|
||||
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
|
||||
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
|
||||
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
|
||||
@@ -1236,98 +953,6 @@ impl Default for TypeScriptSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DependencyScope {
|
||||
#[default]
|
||||
Compile,
|
||||
Test,
|
||||
Runtime,
|
||||
Provided,
|
||||
}
|
||||
|
||||
/// A project-level library (Project Structure › Libraries).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProjectLibrary {
|
||||
pub name: String,
|
||||
/// Class archives (`.jar`) or class directories.
|
||||
pub classes: Vec<String>,
|
||||
/// Source archives (`-sources.jar`, `.zip`) or source directories.
|
||||
pub sources: Vec<String>,
|
||||
/// Documentation archives (`-javadoc.jar`) or directories.
|
||||
pub javadocs: Vec<String>,
|
||||
pub scope: DependencyScope,
|
||||
}
|
||||
|
||||
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ModuleDependency {
|
||||
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
|
||||
pub kind: String,
|
||||
pub target: String,
|
||||
pub scope: DependencyScope,
|
||||
pub exported: bool,
|
||||
}
|
||||
|
||||
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ModuleStructureSettings {
|
||||
/// Module SDK home override; empty = inherit Project SDK.
|
||||
pub jdk: String,
|
||||
/// Module language level override; empty = inherit Project language level.
|
||||
pub language_level: String,
|
||||
/// Module compiler output directory override; empty = inherit Project compiler output.
|
||||
pub compiler_output: String,
|
||||
/// Module test compiler output directory override; empty = default.
|
||||
pub test_compiler_output: String,
|
||||
/// Additional module dependencies.
|
||||
pub dependencies: Vec<ModuleDependency>,
|
||||
}
|
||||
|
||||
/// Framework facet attached to a module (Project Structure › Facets).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FacetConfig {
|
||||
/// `"web"` | `"spring"` | `"jpa"`.
|
||||
pub kind: String,
|
||||
/// Module relative path (`"."` for root module).
|
||||
pub module: String,
|
||||
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
|
||||
pub descriptor: String,
|
||||
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
|
||||
pub web_root: String,
|
||||
}
|
||||
|
||||
/// Packaging type of a Project Structure artifact.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArtifactKind {
|
||||
#[default]
|
||||
Jar,
|
||||
War,
|
||||
WarExploded,
|
||||
}
|
||||
|
||||
/// Packaging artifact definition (Project Structure › Artifacts).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArtifactConfig {
|
||||
pub name: String,
|
||||
pub kind: ArtifactKind,
|
||||
/// Module relative path (`"."` for root module).
|
||||
pub module: String,
|
||||
/// Output directory (relative to project root or absolute).
|
||||
pub output_directory: String,
|
||||
/// Main class for executable JAR artifacts.
|
||||
pub main_class: String,
|
||||
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
|
||||
pub include_libraries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProjectStructureSettings {
|
||||
@@ -1342,35 +967,11 @@ 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).
|
||||
pub compiler_output: String,
|
||||
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
|
||||
pub modules: BTreeMap<String, ModuleStructureSettings>,
|
||||
/// Project-level libraries (`JAR`s / class folders / source archives).
|
||||
pub libraries: Vec<ProjectLibrary>,
|
||||
/// Module framework facets (`web`, `spring`, `jpa`).
|
||||
pub facets: Vec<FacetConfig>,
|
||||
/// Packaging artifacts (`jar`, `war`, `warExploded`).
|
||||
pub artifacts: Vec<ArtifactConfig>,
|
||||
}
|
||||
|
||||
impl ProjectStructureSettings {
|
||||
/// Overrides belong to the module's full path, never just its directory name.
|
||||
pub fn module_settings(
|
||||
&self,
|
||||
root: &std::path::Path,
|
||||
module: &std::path::Path,
|
||||
) -> Option<&ModuleStructureSettings> {
|
||||
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
|
||||
}
|
||||
|
||||
/// The "Exclude files" name patterns.
|
||||
pub fn exclude_file_patterns(&self) -> Vec<String> {
|
||||
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
|
||||
@@ -1408,26 +1009,6 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_close_settings_default_off_and_round_trip() {
|
||||
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
|
||||
let settings: Settings = serde_json::from_str(old).unwrap();
|
||||
assert!(!settings.general.close_without_welcome);
|
||||
assert!(!settings.general.close_tabs_with_window);
|
||||
}
|
||||
for close_window in [false, true] {
|
||||
for close_tabs in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.general.close_without_welcome = close_window;
|
||||
settings.general.close_tabs_with_window = close_tabs;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
|
||||
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
|
||||
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_profile_argument_arrays_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
@@ -1490,21 +1071,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docstring_format_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
|
||||
assert_eq!(old.editor.docstring_format, "rest");
|
||||
assert_eq!(Settings::default().editor.docstring_format, "rest");
|
||||
for format in ["plain", "rest", "epytext", "google", "numpy"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.editor.docstring_format = format.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["editor"]["docstringFormat"], format);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.editor.docstring_format, format);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
@@ -1520,27 +1086,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,33 +1100,13 @@ 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();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
assert!(old.appearance.main_menu_alt);
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
|
||||
assert!(!off.appearance.main_menu_alt);
|
||||
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1627,81 +1152,12 @@ mod tests {
|
||||
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
|
||||
assert!(Settings::default().files.file_types.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
|
||||
assert!(old.project_structure.compiler_output.is_empty());
|
||||
assert!(old.project_structure.modules.is_empty());
|
||||
assert!(old.project_structure.libraries.is_empty());
|
||||
assert!(old.project_structure.facets.is_empty());
|
||||
assert!(old.project_structure.artifacts.is_empty());
|
||||
assert!(old.languages.java.language_level.is_empty());
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.language_level = "21".into();
|
||||
settings.project_structure.compiler_output = "out/production".into();
|
||||
settings.project_structure.modules.insert(
|
||||
"backend".into(),
|
||||
ModuleStructureSettings {
|
||||
jdk: "/opt/jdk-21".into(),
|
||||
language_level: "21".into(),
|
||||
compiler_output: "backend/target/classes".into(),
|
||||
test_compiler_output: "backend/target/test-classes".into(),
|
||||
dependencies: vec![ModuleDependency {
|
||||
kind: "library".into(),
|
||||
target: "custom-lib".into(),
|
||||
scope: DependencyScope::Compile,
|
||||
exported: true,
|
||||
}],
|
||||
},
|
||||
);
|
||||
settings.project_structure.libraries.push(ProjectLibrary {
|
||||
name: "custom-lib".into(),
|
||||
classes: vec!["lib/custom.jar".into()],
|
||||
sources: vec!["lib/custom-sources.jar".into()],
|
||||
javadocs: Vec::new(),
|
||||
scope: DependencyScope::Compile,
|
||||
});
|
||||
settings.project_structure.facets.push(FacetConfig {
|
||||
kind: "web".into(),
|
||||
module: "backend".into(),
|
||||
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
|
||||
web_root: "src/main/webapp".into(),
|
||||
});
|
||||
settings.project_structure.artifacts.push(ArtifactConfig {
|
||||
name: "backend:war exploded".into(),
|
||||
kind: ArtifactKind::WarExploded,
|
||||
module: "backend".into(),
|
||||
output_directory: "out/artifacts/backend_war_exploded".into(),
|
||||
main_class: String::new(),
|
||||
include_libraries: vec!["custom-lib".into()],
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
|
||||
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
|
||||
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
|
||||
let restored: Settings = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored, settings);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maven_runner_jre_defaults_and_round_trips() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.runner_jre.is_empty());
|
||||
assert!(MavenSettings::default().runner_jre.is_empty());
|
||||
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_linkage_defaults_and_round_trips() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
@@ -1748,24 +1204,6 @@ mod maven_linkage_tests {
|
||||
assert_eq!(threads("2..5C").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_combine_settings_and_tool_window_choices() {
|
||||
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
|
||||
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
|
||||
let settings =
|
||||
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_count_round_trips_and_defaults_to_empty() {
|
||||
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
|
||||
@@ -1778,26 +1216,6 @@ mod maven_linkage_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_jdtls_defaults_off_and_round_trips() {
|
||||
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
|
||||
let java: JavaSettings = serde_json::from_str(old).unwrap();
|
||||
assert!(!java.jdtls_shared);
|
||||
}
|
||||
for shared in [false, true] {
|
||||
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
|
||||
let json = serde_json::to_value(&java).unwrap();
|
||||
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
|
||||
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
|
||||
}
|
||||
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
#[default]
|
||||
Github,
|
||||
Cnb,
|
||||
}
|
||||
|
||||
impl UpdateSource {
|
||||
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
|
||||
match preference {
|
||||
"github" => return Self::Github,
|
||||
"cnb" => return Self::Cnb,
|
||||
_ => {}
|
||||
}
|
||||
let locale = locale.trim();
|
||||
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
|
||||
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
|
||||
Self::Cnb
|
||||
} else {
|
||||
Self::Github
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
pub notes: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateCheck {
|
||||
pub enabled: bool,
|
||||
pub update: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateStatus {
|
||||
/// Unix milliseconds of the last successful check in this process.
|
||||
pub last_checked: Option<f64>,
|
||||
pub checking: bool,
|
||||
}
|
||||
|
||||
/// One schedule and announcement history for the desktop process, independent of its windows.
|
||||
#[derive(Default)]
|
||||
pub struct UpdatePolicy {
|
||||
attempted: Option<Instant>,
|
||||
pub status: UpdateStatus,
|
||||
announced: HashSet<String>,
|
||||
}
|
||||
|
||||
impl UpdatePolicy {
|
||||
pub fn due_in(&self, now: Instant) -> Duration {
|
||||
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
|
||||
}
|
||||
|
||||
pub fn begin(&mut self, now: Instant) {
|
||||
self.attempted = Some(now);
|
||||
self.status.checking = true;
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, checked_at: Option<f64>) {
|
||||
self.status.checking = false;
|
||||
if let Some(at) = checked_at {
|
||||
self.status.last_checked = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn announced(&self, version: &str) -> bool {
|
||||
self.announced.contains(version)
|
||||
}
|
||||
|
||||
/// Manual checks always show their result, but also count as an announcement.
|
||||
pub fn announce(&mut self, version: &str) -> bool {
|
||||
self.announced.insert(version.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
|
||||
for (preference, locale, system, expected) in [
|
||||
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
|
||||
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
|
||||
("auto", "system", "zh-CN", UpdateSource::Cnb),
|
||||
("auto", "auto", "zh-TW", UpdateSource::Cnb),
|
||||
("auto", "", "zh", UpdateSource::Cnb),
|
||||
("auto", "en", "zh-CN", UpdateSource::Github),
|
||||
("github", "zh", "zh-CN", UpdateSource::Github),
|
||||
("cnb", "en", "en-US", UpdateSource::Cnb),
|
||||
] {
|
||||
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_at_startup_and_every_four_hours_even_after_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
assert_eq!(policy.due_in(now), Duration::ZERO);
|
||||
policy.begin(now);
|
||||
assert!(policy.status.checking);
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, None);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
|
||||
policy.begin(now + CHECK_INTERVAL);
|
||||
policy.finish(Some(1234.0));
|
||||
assert_eq!(policy.status.last_checked, Some(1234.0));
|
||||
assert!(!policy.status.checking);
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
let now = Instant::now();
|
||||
policy.begin(now);
|
||||
policy.finish(Some(1000.0));
|
||||
policy.begin(now + Duration::from_secs(60));
|
||||
policy.finish(None);
|
||||
assert_eq!(policy.status.last_checked, Some(1000.0));
|
||||
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
|
||||
let mut policy = UpdatePolicy::default();
|
||||
assert!(policy.announce("0.1.40"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(policy.announce("0.1.41"));
|
||||
assert!(!policy.announce("0.1.40"));
|
||||
assert!(UpdatePolicy::default().announce("0.1.40"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -115,15 +113,6 @@ pub struct CommitSettings {
|
||||
pub body_limit: u32,
|
||||
pub show_right_margin: bool,
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
/// 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 {
|
||||
@@ -143,8 +132,6 @@ impl Default for CommitSettings {
|
||||
body_limit: 72,
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
double_click_opens_diff: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,8 +269,6 @@ impl Default for PythonDebuggerSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
/// Target for configurations without an explicit target. Empty = local.
|
||||
pub default_target: String,
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
@@ -294,13 +279,7 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_target: String::new(),
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +301,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 +333,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 +389,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(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -30,4 +25,4 @@ libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
|
||||
+76
-345
@@ -101,15 +101,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
|
||||
// (`prepare_gdb` refuses older ones with the required version in the message).
|
||||
"gdb" => cfg(
|
||||
"gdb",
|
||||
&["-i", "dap"],
|
||||
"stdio",
|
||||
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
|
||||
),
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
"com.microsoft.java.debug.plugin.jar",
|
||||
@@ -135,7 +126,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
|
||||
"delve" => "go",
|
||||
"netcoredbg" => "coreclr",
|
||||
"lldb-dap" => "lldb-dap",
|
||||
"gdb" => "gdb",
|
||||
"java-debug" => "java",
|
||||
_ => return None,
|
||||
})
|
||||
@@ -175,11 +165,6 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
|
||||
}
|
||||
|
||||
/// A port that was free a moment ago on the loopback interface.
|
||||
///
|
||||
/// Inherently racy: another process can bind the port between the listener being
|
||||
/// dropped here and the adapter binding it. Callers that hand the port to a child
|
||||
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
|
||||
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
|
||||
pub fn free_port() -> std::io::Result<u16> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
@@ -341,89 +326,6 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
|
||||
/// "Interpreter `dap' unrecognized".
|
||||
const GDB_MIN_MAJOR: u32 = 14;
|
||||
|
||||
/// `(major, minor)` of the first version number in the output of `gdb --version`
|
||||
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
|
||||
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
|
||||
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
|
||||
let line = output.lines().next()?;
|
||||
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
|
||||
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
|
||||
let b = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if !b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
let major: u32 = line[start..i].parse().ok()?;
|
||||
let mut minor = 0;
|
||||
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
|
||||
i += 1;
|
||||
let ms = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
minor = line[ms..i].parse().unwrap_or(0);
|
||||
}
|
||||
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
|
||||
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
|
||||
continue;
|
||||
}
|
||||
return Some((major, minor));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe the already resolved and verified executable with its launch environment.
|
||||
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
|
||||
let mut probe = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
probe.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(probe.as_std_mut());
|
||||
let probe = probe
|
||||
.arg("--version")
|
||||
.current_dir(&spec.cwd)
|
||||
.envs(spec.env.iter().cloned())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
|
||||
/// Refuse old GDB versions and report whether launch must follow configurationDone.
|
||||
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
|
||||
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
|
||||
let Some((major, minor)) = gdb_version(binary, spec).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if major >= GDB_MIN_MAJOR {
|
||||
return Ok(major == 15);
|
||||
}
|
||||
let mut msg = format!(
|
||||
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
|
||||
this one has no DAP interpreter (`gdb -i dap`)"
|
||||
);
|
||||
if let Some(hint) = &spec.install_hint {
|
||||
msg.push_str(". ");
|
||||
msg.push_str(hint);
|
||||
}
|
||||
Err(IdeError::unavailable(msg))
|
||||
}
|
||||
|
||||
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
|
||||
/// than a program: it has no process of its own and is reached through `debugServer`.
|
||||
pub fn is_plugin_bundle(command: &str) -> bool {
|
||||
@@ -457,8 +359,6 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
|
||||
|
||||
/// A connected adapter: byte streams plus the process (if we spawned one).
|
||||
pub struct AdapterIo {
|
||||
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
|
||||
pub launch_after_configuration: bool,
|
||||
pub reader: BoxReader,
|
||||
pub writer: BoxWriter,
|
||||
pub child: Option<Child>,
|
||||
@@ -473,8 +373,8 @@ pub struct AdapterIo {
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// These debuggees inherit adapter stdio; no DAP event carries their output.
|
||||
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
@@ -493,9 +393,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Probe of a `connect` address before starting the command.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
|
||||
/// claimed by another process before the adapter binds it, so a new one usually wins.
|
||||
const PORT_ATTEMPTS: u32 = 3;
|
||||
|
||||
impl AdapterLauncher for ProcessLauncher {
|
||||
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
|
||||
@@ -510,7 +407,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let stream = connect_with_retries(host, port, None, timeout).await?;
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -534,7 +430,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let _ = stream.set_nodelay(true);
|
||||
let (r, w) = stream.into_split();
|
||||
return Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
@@ -563,152 +458,87 @@ 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)
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => Some(free_port()?),
|
||||
},
|
||||
Transport::Stdio => None,
|
||||
};
|
||||
let args = substitute_root(&spec.args, &spec.cwd);
|
||||
let args = match port {
|
||||
Some(port) => substitute_port(&args, port),
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Transport::Tcp => {
|
||||
// An embedded adapter started without `${port}` listens on its usual
|
||||
// address; every other TCP adapter gets a port chosen here so it can be
|
||||
// retried if another process takes it before the adapter binds.
|
||||
let fixed = match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => None,
|
||||
};
|
||||
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
for attempt in 1..=PORT_ATTEMPTS {
|
||||
let port = match fixed {
|
||||
Some(port) => port,
|
||||
None => free_port()?,
|
||||
};
|
||||
let args = substitute_port(&base_args, port);
|
||||
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
|
||||
Ok(mut io) => {
|
||||
io.launch_after_configuration = launch_after_configuration;
|
||||
return Ok(io);
|
||||
}
|
||||
Err(e) => {
|
||||
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
|
||||
tracing::warn!(
|
||||
target: "ide_dap::adapter",
|
||||
"debug adapter failed to start on port {port}: {}; retrying",
|
||||
e.message
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
unreachable!("the retry loop returns on its last attempt")
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
|
||||
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
|
||||
async fn spawn_adapter(
|
||||
spec: &AdapterSpec,
|
||||
binary: &Path,
|
||||
args: &[String],
|
||||
port: Option<u16>,
|
||||
start_timeout: Duration,
|
||||
) -> IdeResult<AdapterIo> {
|
||||
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let mut cmd = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
// Every adapter leads its own process group: the program an adapter launches itself
|
||||
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
|
||||
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.args(args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
launch_after_configuration: false,
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed adapter start that another process grabbing the free port can explain
|
||||
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
|
||||
/// it could not bind). Anything else is a real start failure and is not retried.
|
||||
fn is_port_race(err: &IdeError) -> bool {
|
||||
let message = err.message.to_ascii_lowercase();
|
||||
message.contains("address already in use")
|
||||
|| message.contains("addrinuse")
|
||||
|| message.contains("only one usage of each socket address")
|
||||
|| message.contains("exited before accepting connections")
|
||||
}
|
||||
|
||||
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
@@ -844,102 +674,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builtins_cover_common_adapters() {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
|
||||
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
|
||||
let cfg = builtin(name).unwrap();
|
||||
assert!(cfg.install_hint.is_some());
|
||||
}
|
||||
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
|
||||
assert_eq!(delve.transport, Transport::Tcp);
|
||||
// GDB's own DAP interpreter over stdio, with the required version in its hint.
|
||||
let gdb = builtin("gdb").unwrap();
|
||||
assert_eq!(gdb.command, "gdb");
|
||||
assert_eq!(gdb.args, ["-i", "dap"]);
|
||||
assert_eq!(gdb.transport, "stdio");
|
||||
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
|
||||
assert_eq!(default_type("gdb"), Some("gdb"));
|
||||
assert!(builtin("nope").is_none());
|
||||
}
|
||||
|
||||
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
|
||||
#[test]
|
||||
fn parses_gdb_versions() {
|
||||
for (out, want) in [
|
||||
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
|
||||
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
|
||||
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
|
||||
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
|
||||
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
|
||||
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
|
||||
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
|
||||
("GNU gdb 14", Some((14, 0))),
|
||||
("no version here", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(parse_gdb_version(out), want, "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_never_executes_a_changed_pinned_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
std::fs::write(&fake, "approved").unwrap();
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
spec.command = fake.to_string_lossy().into_owned();
|
||||
spec.pinned = Some(pin);
|
||||
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
|
||||
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
|
||||
#[cfg(windows)]
|
||||
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let pin = ProgramPin::approve(&fake).unwrap();
|
||||
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
|
||||
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
|
||||
// Only the approved path is executable; the symbolic command must never be probed.
|
||||
spec.command = "definitely-not-a-real-binary-openidea".into();
|
||||
spec.pinned = Some(pin.clone());
|
||||
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
|
||||
let result = ProcessLauncher.launch(spec, None).await;
|
||||
if version == "12.1" {
|
||||
let error = result.err().expect("GDB 12 has no DAP interpreter");
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
|
||||
assert!(error.message.contains("12.1"), "{}", error.message);
|
||||
} else {
|
||||
let mut io = result.unwrap();
|
||||
assert_eq!(io.launch_after_configuration, deferred, "{version}");
|
||||
let mut output = String::new();
|
||||
io.reader.read_to_string(&mut output).await.unwrap();
|
||||
assert_eq!(output.trim(), "adapter");
|
||||
io.child.as_mut().unwrap().wait().await.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_port_is_nonzero() {
|
||||
assert_ne!(free_port().unwrap(), 0);
|
||||
@@ -1023,16 +766,4 @@ mod tests {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
|
||||
);
|
||||
assert!(is_port_race(&in_use));
|
||||
assert!(is_port_race(&IdeError::unavailable(
|
||||
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
|
||||
)));
|
||||
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,13 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let Some(msg) = parse_message(body) else { return };
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
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 +285,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 +306,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 +313,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
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -9,13 +9,6 @@ use std::{
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-adapter";
|
||||
|
||||
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
|
||||
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
|
||||
/// until the attach is done.
|
||||
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
|
||||
ide_jdwp::LinkOptions { hold_resumes: true }
|
||||
}
|
||||
|
||||
pub struct SourceMirror {
|
||||
dir: tempfile::TempDir,
|
||||
paths: HashMap<PathBuf, PathBuf>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-279
@@ -13,8 +13,8 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -22,7 +22,6 @@ 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};
|
||||
@@ -127,14 +126,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`].
|
||||
@@ -291,6 +282,7 @@ impl SessionOwner {
|
||||
}
|
||||
|
||||
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Redefining many classes in a large JVM can take a while.
|
||||
@@ -311,64 +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,
|
||||
/// arguments and environment: they go on its command line after `--` and into its process.
|
||||
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
|
||||
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
|
||||
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
|
||||
if config.request != "launch" {
|
||||
return Ok(());
|
||||
}
|
||||
if spec.command == "rdbg"
|
||||
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
|
||||
{
|
||||
if !bundles_debug(&gemfile) {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
|
||||
gemfile.display()
|
||||
)));
|
||||
}
|
||||
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
|
||||
spec.command = "bundle".into();
|
||||
}
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
spec.args.push("--".into());
|
||||
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
|
||||
// without arguments go through -S so spaces and metacharacters stay literal.
|
||||
if config.program.is_some() && config.args.is_empty() {
|
||||
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
|
||||
}
|
||||
spec.args.extend(config.program.iter().cloned());
|
||||
spec.args.extend(config.args.iter().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
|
||||
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
|
||||
let gemfile = env
|
||||
.get("BUNDLE_GEMFILE")
|
||||
.cloned()
|
||||
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
|
||||
match gemfile.filter(|g| !g.is_empty()) {
|
||||
Some(gemfile) => Some(dir.join(gemfile)),
|
||||
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the lockfile of `gemfile` lists the `debug` gem.
|
||||
fn bundles_debug(gemfile: &Path) -> bool {
|
||||
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
|
||||
gemfile.with_file_name("gems.locked")
|
||||
} else {
|
||||
let mut name = gemfile.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
};
|
||||
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
|
||||
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
@@ -576,21 +510,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
|
||||
@@ -629,22 +551,11 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
|| s == ".exe"
|
||||
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
|
||||
});
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
// Windows: a just-killed dlv / debuggee can still hold this session's
|
||||
// binary while the previous one's lock is already gone, so one try
|
||||
// leaves exactly the current leftover behind. Retry on a short back-off
|
||||
// to outlast the handle release; a truly busy file is retried into the
|
||||
// next run's sweep anyway.
|
||||
for attempt in 0..5 {
|
||||
match std::fs::remove_file(entry.path()) {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt == 4 => {
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
|
||||
}
|
||||
if leftover
|
||||
&& entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& let Err(e) = std::fs::remove_file(entry.path())
|
||||
{
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,15 +590,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))
|
||||
}
|
||||
@@ -746,13 +651,6 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
// Delve looks up Go before applying the debuggee's launch environment.
|
||||
if config.adapter == "delve" {
|
||||
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
@@ -782,18 +680,6 @@ impl DebugManager {
|
||||
if let Some(server) = &debug_server {
|
||||
spec.server_host = server.host.clone();
|
||||
}
|
||||
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
|
||||
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
|
||||
// instead belong on the adapter process, which the inferior inherits. Explicit
|
||||
// launch JSON keeps GDB's replacement semantics.
|
||||
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
|
||||
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
|
||||
});
|
||||
if !explicit_env {
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
arguments.as_object_mut().unwrap().remove("env");
|
||||
}
|
||||
}
|
||||
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
|
||||
let cargo_args: Vec<String> = cargo
|
||||
.get("args")
|
||||
@@ -903,29 +789,19 @@ impl DebugManager {
|
||||
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 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)))
|
||||
};
|
||||
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
|
||||
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
|
||||
.flatten();
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
// rdbg execs the debuggee in its own process and sends no DAP process event.
|
||||
if let Some(pid) = rdbg_pid {
|
||||
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
|
||||
}
|
||||
if let Some(mut output) = io.program_output {
|
||||
session.output_drained.send_replace(false);
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
session.output_drained.send_replace(true);
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
@@ -938,12 +814,7 @@ impl DebugManager {
|
||||
});
|
||||
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
|
||||
|
||||
let configured = tokio::select! {
|
||||
biased;
|
||||
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = self.configure(&session, arguments, launch_after_configuration) => result,
|
||||
};
|
||||
match configured {
|
||||
match self.configure(&session, arguments).await {
|
||||
Ok(()) => Ok(id),
|
||||
Err(e) => {
|
||||
let e = if session.conn.is_closed() {
|
||||
@@ -968,7 +839,7 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
|
||||
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
let project = arguments
|
||||
.get("projectRoot")
|
||||
@@ -1007,31 +878,13 @@ 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.
|
||||
// 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 s.adapter == kotlin::ADAPTER {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
@@ -1044,40 +897,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()).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)) }))
|
||||
};
|
||||
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
let mut launch_result = None;
|
||||
let got_initialized = match launch.as_mut() {
|
||||
Some(launch) => tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
},
|
||||
None => {
|
||||
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
|
||||
true
|
||||
let got_initialized = tokio::select! {
|
||||
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
|
||||
r = &mut launch => {
|
||||
r?;
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
};
|
||||
if !got_initialized {
|
||||
// Launched before `initialized`; give the adapter a moment to send it.
|
||||
@@ -1134,20 +971,16 @@ impl DebugManager {
|
||||
}
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
match launch.as_mut() {
|
||||
Some(launch) => {
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
if launch_result.is_none() {
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
|
||||
Ok(r) => {
|
||||
r?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
|
||||
let reply = s.conn.send_request(request, arguments)?;
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
}
|
||||
if let Some(link) = s.jdwp_link.lock().as_ref() {
|
||||
link.release();
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1164,18 +997,13 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
// the console (`terminated`), as IDEA does with the process it owns.
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
let own_terminate = terminate && s.request == "launch" && pid > 0;
|
||||
// rdbg's terminate exits the Ruby process without terminating its children.
|
||||
let graceful =
|
||||
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
|
||||
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
|
||||
if !s.conn.is_closed() {
|
||||
if graceful {
|
||||
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
|
||||
@@ -1194,7 +1022,7 @@ impl DebugManager {
|
||||
if own_terminate && !graceful {
|
||||
// Signal before anything else: waiting on a request the adapter may not answer
|
||||
// would delay the shutdown the console is supposed to show.
|
||||
crate::session::signal_debuggee(s, false).await;
|
||||
crate::session::signal_debuggee(s, false);
|
||||
}
|
||||
if graceful {
|
||||
// java-debug sends `terminated` only after it has drained the program's console
|
||||
@@ -1380,9 +1208,8 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
let session = self.session(s)?;
|
||||
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| {
|
||||
@@ -1393,8 +1220,7 @@ impl DebugManager {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(threads)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
|
||||
@@ -1523,11 +1349,8 @@ impl DebugManager {
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
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(())
|
||||
}
|
||||
@@ -1567,10 +1390,7 @@ impl DebugManager {
|
||||
StepKind::Out => "stepOut",
|
||||
};
|
||||
let session = self.session(s)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
// A step runs its thread; other threads stopped by their own events stay stopped.
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1668,12 +1488,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)?;
|
||||
let ticket = session.suspended.ticket();
|
||||
session
|
||||
.conn
|
||||
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
|
||||
.await?;
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
}
|
||||
@@ -1707,68 +1525,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
|
||||
@@ -1899,11 +1659,9 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
|
||||
session.startup_cancel.cancel();
|
||||
let children: Vec<Arc<Session>> =
|
||||
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
|
||||
for child in children {
|
||||
child.startup_cancel.cancel();
|
||||
self.shutdown(&child, true).await;
|
||||
}
|
||||
self.shutdown(&session, true).await;
|
||||
|
||||
+61
-561
@@ -5,8 +5,6 @@ use crate::adapter::AdapterSpec;
|
||||
use crate::connection::{Connection, DapError, Incoming};
|
||||
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};
|
||||
@@ -47,38 +45,18 @@ pub(crate) struct Session {
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// The job object over the adapter and everything it started: the program an adapter
|
||||
/// like java-debug launches is its child, so a forced stop (and the session's
|
||||
/// cleanup) ends the whole tree even when the adapter exited first and the reported
|
||||
/// pid no longer names it. `None` when the assignment failed; the kill then falls
|
||||
/// back to the captured process tree.
|
||||
#[cfg(windows)]
|
||||
pub job: Mutex<Option<ide_core::process::Job>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub startup_cancel: ide_core::CancellationToken,
|
||||
pub output_drained: watch::Sender<bool>,
|
||||
pub terminated: AtomicBool,
|
||||
/// Set by a second Stop: the one waiting for the program kills it instead.
|
||||
pub stop_force: AtomicBool,
|
||||
/// `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))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -86,105 +64,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>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -206,10 +87,6 @@ impl Session {
|
||||
port: Option<u16>,
|
||||
child: Option<Child>,
|
||||
) -> Self {
|
||||
// Before `child` moves into the session: the adapter is the root of the job, so
|
||||
// everything it starts (java-debug's JVM) is in there by inheritance.
|
||||
#[cfg(windows)]
|
||||
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
|
||||
Self {
|
||||
id,
|
||||
owner,
|
||||
@@ -224,34 +101,18 @@ impl Session {
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(job),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
output_drained: watch::channel(true).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
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),
|
||||
pending_restore: Mutex::default(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,34 +273,8 @@ 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()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
@@ -447,9 +282,6 @@ impl Session {
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
|
||||
let _ = program.child.wait();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,54 +289,47 @@ impl Session {
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
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.
|
||||
@@ -515,53 +340,17 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Console programs have no external graceful signal; the adapter gets its chance
|
||||
// first. The job ends the program and everything it started, members included
|
||||
// that the captured tree never saw.
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
if let Some(job) = s.job.lock().as_ref() {
|
||||
job.terminate();
|
||||
}
|
||||
// A separately spawned program may have rejected assignment to the adapter's job.
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
cmd.arg("/F");
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
}
|
||||
|
||||
/// The process groups the session spawned roots for: the adapter's (the program it
|
||||
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
|
||||
#[cfg(unix)]
|
||||
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
|
||||
let mut groups = Vec::new();
|
||||
let mut push = |id: Option<u32>| {
|
||||
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
|
||||
groups.push(pid);
|
||||
}
|
||||
};
|
||||
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
|
||||
for child in s.debuggees.lock().iter() {
|
||||
push(child.id());
|
||||
}
|
||||
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
|
||||
groups.retain(|g| *g > 0);
|
||||
groups
|
||||
}
|
||||
|
||||
/// Whether the launch is still running: the stop's captured tree when there is one,
|
||||
/// otherwise the session's job (the adapter and everything it started).
|
||||
#[cfg(windows)]
|
||||
fn launch_running(s: &Arc<Session>) -> bool {
|
||||
if let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.is_running()
|
||||
} else {
|
||||
s.job.lock().as_ref().is_some_and(|job| job.is_running())
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
|
||||
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
if !terminate || s.request != "launch" {
|
||||
@@ -575,39 +364,25 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::time::Instant;
|
||||
let mut targets = s.stop_pids.lock().clone();
|
||||
if targets.is_empty() {
|
||||
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
|
||||
}
|
||||
let targets = {
|
||||
let known = s.stop_pids.lock();
|
||||
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
|
||||
};
|
||||
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
|
||||
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while alive() {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) {
|
||||
signal_debuggee(s, true).await;
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// The program ignored the terminate: do not leave it running.
|
||||
signal_debuggee(s, true).await;
|
||||
signal_debuggee(s, true);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
// The captured tree is the oracle when it was captured; otherwise the job (every
|
||||
// process the launch started) is, so a failed capture can no longer end the wait
|
||||
// while the program still holds its port.
|
||||
while launch_running(s) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -673,11 +448,6 @@ pub(crate) async fn event_loop(
|
||||
Incoming::Closed => break,
|
||||
}
|
||||
}
|
||||
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
|
||||
if session.adapter == "rdbg" && session.request == "launch" {
|
||||
let mut drained = session.output_drained.subscribe();
|
||||
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
|
||||
}
|
||||
if let Some(mgr) = manager.upgrade() {
|
||||
mgr.session_closed(&session);
|
||||
}
|
||||
@@ -695,27 +465,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 reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
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" {
|
||||
@@ -827,14 +588,15 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write(text.as_bytes())
|
||||
self.writer.write_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,16 +616,6 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_writable = ide_core::input::nonblocking(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_readable = ide_core::input::readable(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
@@ -889,39 +641,12 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
// The program joins the session's job, so Stop reaches what it starts (its children),
|
||||
// though only the program itself is the session's child.
|
||||
#[cfg(windows)]
|
||||
if let Some(pid) = child.process_id()
|
||||
&& let Some(job) = s.job.lock().as_ref()
|
||||
{
|
||||
job.assign_pid(pid);
|
||||
}
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = ide_core::input::InputWriter::new(
|
||||
format!("openidea-debug-input-{}", s.id.0),
|
||||
writer,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|e| format!("terminal input worker: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(
|
||||
s,
|
||||
reader,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -930,11 +655,7 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(
|
||||
s: &Session,
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -942,27 +663,8 @@ fn forward_terminal_output(
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = wait_readable()
|
||||
&& e.kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
|
||||
let mut valid = match std::str::from_utf8(&pending) {
|
||||
@@ -1021,22 +723,10 @@ 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};
|
||||
let port = crate::adapter::free_port()?;
|
||||
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
args.get(key)
|
||||
@@ -1048,10 +738,8 @@ 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)))
|
||||
// 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")
|
||||
cmd.args(strings("vmArguments"))
|
||||
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
|
||||
.arg("-cp")
|
||||
.arg(classpath);
|
||||
let modules = strings("modulePaths");
|
||||
@@ -1078,34 +766,20 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
|
||||
// forced stop), the session's job on Windows, so a stop reaches what it forks.
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
let mut child = cmd.spawn()?;
|
||||
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");
|
||||
}
|
||||
let mut stdout =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
|
||||
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
|
||||
// the free-port race window entirely.
|
||||
let port = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stdout.read_line(&mut line).await? == 0 {
|
||||
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
|
||||
}
|
||||
if line.contains("Listening for transport dt_socket") {
|
||||
return parse_jdwp_port(&line).ok_or_else(|| {
|
||||
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
|
||||
}
|
||||
@@ -1119,177 +793,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
args["timeout"] = json!(10000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
|
||||
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
|
||||
fn parse_jdwp_port(line: &str) -> Option<u16> {
|
||||
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{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()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_port_the_jvm_chose() {
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("unrelated output"), None);
|
||||
}
|
||||
}
|
||||
+35
-1012
File diff suppressed because it is too large.
Load diff
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -7,20 +7,17 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracledb.workspace = true
|
||||
oracle.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
+87
-376
@@ -12,17 +12,14 @@ use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use sqlx::{
|
||||
Column, ColumnIndex, Connection, Decode, Executor, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
|
||||
mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode},
|
||||
Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
|
||||
mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode},
|
||||
pool::PoolConnection,
|
||||
postgres::{PgConnectOptions, PgConnection, PgSslMode},
|
||||
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
|
||||
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -50,135 +47,12 @@ pub struct QueryResult {
|
||||
pub execution_ms: f64,
|
||||
}
|
||||
|
||||
/// Session context belongs to the physical connection, including across pool checkouts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Context {
|
||||
default: Option<String>,
|
||||
current: Option<String>,
|
||||
known: bool,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub(crate) fn new(default: Option<String>) -> Self {
|
||||
Self { current: default.clone(), default, known: true }
|
||||
}
|
||||
|
||||
pub(crate) fn set_current(&mut self, current: Option<String>) {
|
||||
self.current = current;
|
||||
}
|
||||
|
||||
fn wanted(&self, driver: DbDriver, schema: Option<&str>) -> Option<String> {
|
||||
schema
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| crate::sql::use_schema(driver, s))
|
||||
.or_else(|| self.default.clone())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! sqlx_pool {
|
||||
($manager:ident, $handle:ident, $options:ty, $connection:ty, $driver:expr, $query:expr) => {
|
||||
pub struct $manager {
|
||||
options: $options,
|
||||
init: Arc<Vec<String>>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
pub struct $handle {
|
||||
conn: Option<$connection>,
|
||||
context: Context,
|
||||
options: $options,
|
||||
init: Arc<Vec<String>>,
|
||||
timeout: Duration,
|
||||
in_transaction: bool,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for $handle {
|
||||
type Target = $connection;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.conn.as_ref().expect("live connection")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for $handle {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.conn.as_mut().expect("live connection")
|
||||
}
|
||||
}
|
||||
|
||||
impl ManageConnection for $manager {
|
||||
type Connection = $handle;
|
||||
type Error = sqlx::Error;
|
||||
|
||||
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
||||
tokio::time::timeout(self.timeout, async {
|
||||
let mut conn = <$connection>::connect_with(&self.options).await?;
|
||||
let context_sql = |value: Option<String>| {
|
||||
value.map(|v| {
|
||||
if $driver == DbDriver::Postgres {
|
||||
format!("SET search_path TO {v}")
|
||||
} else {
|
||||
crate::sql::use_schema($driver, &v).expect("switchable driver")
|
||||
}
|
||||
})
|
||||
};
|
||||
for sql in self.init.iter() {
|
||||
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut conn).await?;
|
||||
}
|
||||
let row = sqlx::raw_sql(sqlx::AssertSqlSafe($query.to_owned())).fetch_one(&mut conn).await?;
|
||||
let context = Context::new(context_sql(row.try_get(0)?));
|
||||
Ok($handle {
|
||||
conn: Some(conn),
|
||||
context,
|
||||
options: self.options.clone(),
|
||||
init: self.init.clone(),
|
||||
timeout: self.timeout,
|
||||
in_transaction: false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| sqlx::Error::PoolTimedOut)?
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
conn.ping().await
|
||||
}
|
||||
|
||||
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
|
||||
conn.conn.is_none()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
sqlx_pool!(PgManager, PgHandle, PgConnectOptions, PgConnection, DbDriver::Postgres, "SHOW search_path");
|
||||
sqlx_pool!(MysqlManager, MysqlHandle, MySqlConnectOptions, MySqlConnection, DbDriver::Mysql, "SELECT DATABASE()");
|
||||
|
||||
pub struct ServerPool<M: ManageConnection> {
|
||||
inner: Bb8Pool<M>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl<M: ManageConnection> ServerPool<M> {
|
||||
async fn get(&self) -> IdeResult<bb8::PooledConnection<'static, M>>
|
||||
where
|
||||
M::Error: std::fmt::Display,
|
||||
{
|
||||
if self.closed.load(Ordering::Relaxed) {
|
||||
return Err(IdeError::new(ErrorKind::Io, "database connection is closed"));
|
||||
}
|
||||
self.inner.get_owned().await.map_err(|e| match e {
|
||||
bb8::RunError::User(e) => IdeError::new(ErrorKind::Io, e.to_string()),
|
||||
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
|
||||
pub struct MssqlConnectionManager(tiberius::Config);
|
||||
|
||||
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
|
||||
|
||||
pub struct MssqlConnection(Option<MssqlClient>, Context);
|
||||
pub struct MssqlConnection(Option<MssqlClient>);
|
||||
|
||||
impl std::ops::Deref for MssqlConnection {
|
||||
type Target = MssqlClient;
|
||||
@@ -202,7 +76,7 @@ impl ManageConnection for MssqlConnectionManager {
|
||||
|
||||
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
|
||||
tcp.set_nodelay(true)?;
|
||||
let mut client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
|
||||
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
|
||||
// Preserve the single redirect supported by the previous pool adapter.
|
||||
Err(tiberius::error::Error::Routing { host, port }) => {
|
||||
let mut config = self.0.clone();
|
||||
@@ -214,11 +88,7 @@ impl ManageConnection for MssqlConnectionManager {
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
let row = client.simple_query("SELECT DB_NAME()").await?.into_row().await?;
|
||||
let default = row
|
||||
.and_then(|r| r.get::<&str, _>(0).map(str::to_owned))
|
||||
.and_then(|db| crate::sql::use_schema(DbDriver::SqlServer, &db));
|
||||
Ok(MssqlConnection(Some(client), Context::new(default)))
|
||||
Ok(MssqlConnection(Some(client)))
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
@@ -233,13 +103,25 @@ impl ManageConnection for MssqlConnectionManager {
|
||||
|
||||
pub enum Pool {
|
||||
Sqlite(SqlitePool),
|
||||
Postgres(ServerPool<PgManager>),
|
||||
Mysql(ServerPool<MysqlManager>),
|
||||
Postgres(PgPool),
|
||||
Mysql(MySqlPool),
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OraclePool),
|
||||
}
|
||||
|
||||
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
|
||||
macro_rules! with_pool {
|
||||
($pool:expr, $p:ident => $body:expr) => {
|
||||
match $pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
match &e {
|
||||
sqlx::Error::Database(d) => {
|
||||
@@ -417,7 +299,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()),
|
||||
@@ -556,19 +438,6 @@ macro_rules! backend_id {
|
||||
}};
|
||||
}
|
||||
|
||||
// Session control, scripts and PostgreSQL set_config can invalidate the tracked context.
|
||||
fn context_may_change(sql: &str) -> bool {
|
||||
let text = sql.trim().trim_end_matches(';');
|
||||
text.contains(';')
|
||||
|| text.to_ascii_lowercase().contains("set_config")
|
||||
|| !text.split_whitespace().next().is_some_and(|s| {
|
||||
matches!(
|
||||
s.to_ascii_uppercase().as_str(),
|
||||
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP" | "TRUNCATE" | "SHOW" | "EXPLAIN"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_query(sql: &str) -> bool {
|
||||
let first = sql
|
||||
.lines()
|
||||
@@ -594,13 +463,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));
|
||||
@@ -673,13 +535,7 @@ impl Setup {
|
||||
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
|
||||
}
|
||||
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
|
||||
(_, "connect_timeout") => {
|
||||
let seconds = number(&name)?;
|
||||
if seconds == 0 {
|
||||
return Err(IdeError::invalid("connect_timeout must be greater than zero"));
|
||||
}
|
||||
s.connect_timeout = Some(Duration::from_secs(seconds));
|
||||
}
|
||||
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
|
||||
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
|
||||
@@ -806,12 +662,14 @@ impl Pool {
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
let manager = PgManager { options: opts, init: init.clone(), timeout };
|
||||
// Open once directly so authentication failures retain their database error.
|
||||
let first = manager.connect().await.map_err(db_err)?;
|
||||
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
|
||||
let _ = pool.add(first);
|
||||
Ok(Pool::Postgres(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Pool::Postgres(pool))
|
||||
}
|
||||
DbDriver::Mysql => {
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
@@ -846,8 +704,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);
|
||||
}
|
||||
@@ -855,12 +711,14 @@ impl Pool {
|
||||
if !tz.is_empty() {
|
||||
opts = opts.timezone(Some(tz.to_owned()));
|
||||
}
|
||||
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
|
||||
// Open once directly so authentication failures retain their database error.
|
||||
let first = manager.connect().await.map_err(db_err)?;
|
||||
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
|
||||
let _ = pool.add(first);
|
||||
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
|
||||
let pool = MySqlPoolOptions::new()
|
||||
.max_connections(MAX)
|
||||
.acquire_timeout(timeout)
|
||||
.after_connect(after_connect!())
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(Pool::Mysql(pool))
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
use tiberius::Config;
|
||||
@@ -899,9 +757,7 @@ impl Pool {
|
||||
match self {
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
Pool::Sqlite(p) => p.close().await,
|
||||
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,9 +766,7 @@ impl Pool {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Oracle(p) => p.is_closed(),
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
Pool::Sqlite(p) => p.is_closed(),
|
||||
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
|
||||
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,9 +775,18 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
conn.apply_context(None).await?;
|
||||
let fetched = conn.fetch_rows(sql, max).await;
|
||||
if let Pool::Oracle(p) = self {
|
||||
let mut conn = p.get().await?;
|
||||
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
|
||||
return Ok(fetched.1);
|
||||
}
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
mssql_fetch(&mut conn, sql, 0, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
Ok(fetched?.1)
|
||||
}
|
||||
|
||||
@@ -946,10 +809,7 @@ impl Pool {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = match conn.apply_context(None).await {
|
||||
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -962,10 +822,8 @@ impl Pool {
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::SqlServer(_) => {
|
||||
let mut held = Held::acquire(self).await?;
|
||||
held.apply_context(None).await?;
|
||||
let Held::SqlServer(mut conn) = held else { unreachable!() };
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
let result = async {
|
||||
mssql_command(&mut conn, "BEGIN TRAN").await?;
|
||||
let mut total = 0;
|
||||
@@ -988,10 +846,8 @@ impl Pool {
|
||||
}
|
||||
result
|
||||
}
|
||||
Pool::Oracle(_) => {
|
||||
let mut held = Held::acquire(self).await?;
|
||||
held.apply_context(None).await?;
|
||||
let Held::Oracle(mut conn) = held else { unreachable!() };
|
||||
Pool::Oracle(p) => {
|
||||
let mut conn = p.get().await?;
|
||||
let result = crate::oracle::transaction(&mut conn, statements).await;
|
||||
if result.is_err() {
|
||||
// An open transaction must never return to the pool with the connection.
|
||||
@@ -999,37 +855,21 @@ impl Pool {
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => {
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
conn.apply_context(None).await?;
|
||||
macro_rules! transaction {
|
||||
($c:expr) => {{
|
||||
let mut tx = (&mut **$c).begin().await.map_err(db_err)?;
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone()))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
let n = done.rows_affected();
|
||||
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
|
||||
)));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(total)
|
||||
}};
|
||||
_ => with_pool!(self, p => {
|
||||
let mut tx = p.begin().await.map_err(db_err)?;
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
|
||||
let n = done.rows_affected();
|
||||
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
|
||||
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
|
||||
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
match &mut conn {
|
||||
Held::Sqlite(c) => transaction!(c),
|
||||
Held::Postgres(c) => transaction!(&mut ***c),
|
||||
Held::Mysql(c) => transaction!(&mut ***c),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(total)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1038,19 +878,19 @@ impl Pool {
|
||||
/// manual-commit console across statements with its transaction open.
|
||||
pub enum Held {
|
||||
Sqlite(PoolConnection<Sqlite>),
|
||||
Postgres(Box<bb8::PooledConnection<'static, PgManager>>),
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
pub async fn acquire(pool: &Pool) -> IdeResult<Self> {
|
||||
Ok(match pool {
|
||||
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Postgres(p) => Held::Postgres(Box::new(p.get().await?)),
|
||||
Pool::Mysql(p) => Held::Mysql(Box::new(p.get().await?)),
|
||||
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
|
||||
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
|
||||
Pool::Clickhouse(p) => {
|
||||
if p.is_closed() {
|
||||
@@ -1058,125 +898,12 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
fn context(&mut self) -> Option<&mut Context> {
|
||||
match self {
|
||||
Held::Postgres(c) => Some(&mut c.context),
|
||||
Held::Mysql(c) => Some(&mut c.context),
|
||||
Held::SqlServer(c) => Some(&mut c.1),
|
||||
Held::Oracle(c) => Some(c.context()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_context(&mut self) {
|
||||
if let Some(context) = self.context() {
|
||||
context.known = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn before_statement(&mut self, sql: &str) {
|
||||
let control = sql.trim().trim_end_matches(';').trim().to_ascii_uppercase();
|
||||
if let Held::Mysql(c) = self {
|
||||
if matches!(control.as_str(), "BEGIN" | "START TRANSACTION") {
|
||||
c.in_transaction = true;
|
||||
return;
|
||||
}
|
||||
if matches!(control.as_str(), "COMMIT" | "ROLLBACK") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if context_may_change(sql) {
|
||||
self.invalidate_context();
|
||||
}
|
||||
}
|
||||
|
||||
fn after_statement(&mut self, sql: &str) {
|
||||
if let Held::Mysql(c) = self
|
||||
&& matches!(sql.trim().trim_end_matches(';').trim().to_ascii_uppercase().as_str(), "COMMIT" | "ROLLBACK")
|
||||
{
|
||||
c.in_transaction = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
|
||||
let driver = match self {
|
||||
Held::Postgres(_) => DbDriver::Postgres,
|
||||
Held::Mysql(_) => DbDriver::Mysql,
|
||||
Held::SqlServer(_) => DbDriver::SqlServer,
|
||||
Held::Oracle(_) => DbDriver::Oracle,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let context = self.context().expect("switchable connection");
|
||||
let wanted = context.wanted(driver, schema);
|
||||
if context.known && context.current == wanted {
|
||||
return Ok(());
|
||||
}
|
||||
context.known = false;
|
||||
if let Some(sql) = &wanted {
|
||||
self.raw_command(sql).await?;
|
||||
} else {
|
||||
// MySQL cannot USE an absent database. Re-open with the original options.
|
||||
macro_rules! reopen {
|
||||
($c:expr, $manager:ident) => {{
|
||||
let manager = $manager { options: $c.options.clone(), init: $c.init.clone(), timeout: $c.timeout };
|
||||
if $c.in_transaction {
|
||||
return Err(IdeError::invalid("commit or roll back before clearing the selected database"));
|
||||
}
|
||||
let fresh = manager.connect().await.map_err(db_err)?;
|
||||
if let Some(raw) = $c.conn.take() {
|
||||
let _ = raw.close().await;
|
||||
}
|
||||
***$c = fresh;
|
||||
}};
|
||||
}
|
||||
match self {
|
||||
Held::Mysql(c) => reopen!(c, MysqlManager),
|
||||
Held::Postgres(c) => reopen!(c, PgManager),
|
||||
_ => unreachable!("server context has a default"),
|
||||
}
|
||||
}
|
||||
let context = self.context().expect("switchable connection");
|
||||
context.current = wanted;
|
||||
context.known = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore before a manual session returns its physical connection to the pool.
|
||||
pub async fn restore_default(&mut self) -> IdeResult<()> {
|
||||
self.apply_context(None).await
|
||||
}
|
||||
|
||||
async fn fetch_rows(&mut self, sql: &str, max: usize) -> IdeResult<Fetched> {
|
||||
self.before_statement(sql);
|
||||
let result = match self {
|
||||
Held::Sqlite(c) => fetch!(&mut **c, sql, max, Cells::Text),
|
||||
Held::Postgres(c) => fetch!(&mut ****c, sql, max, Cells::Text),
|
||||
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
|
||||
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.after_statement(sql);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
|
||||
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
|
||||
self.before_statement(sql);
|
||||
let result = self.raw_command(sql).await;
|
||||
if result.is_ok() {
|
||||
self.after_statement(sql);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn raw_command(&mut self, sql: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
@@ -1191,8 +918,8 @@ impl Held {
|
||||
let sql = sqlx::AssertSqlSafe(sql.to_owned());
|
||||
match other {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
@@ -1205,15 +932,11 @@ impl Held {
|
||||
pub async fn discard(self) {
|
||||
match self {
|
||||
Held::Sqlite(c) => drop(c.detach()),
|
||||
Held::Postgres(mut c) => {
|
||||
if let Some(raw) = c.conn.take() {
|
||||
let _ = raw.close().await;
|
||||
}
|
||||
Held::Postgres(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
}
|
||||
Held::Mysql(mut c) => {
|
||||
if let Some(raw) = c.conn.take() {
|
||||
let _ = raw.close().await;
|
||||
}
|
||||
Held::Mysql(c) => {
|
||||
let _ = c.detach().close().await;
|
||||
}
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
@@ -1248,7 +971,6 @@ impl Held {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
self.before_statement(sql);
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
macro_rules! run {
|
||||
@@ -1281,9 +1003,9 @@ impl Held {
|
||||
let fetched: Fetched = match self {
|
||||
Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()),
|
||||
Held::Postgres(c) => {
|
||||
run!(&mut ***c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
|
||||
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
|
||||
}
|
||||
Held::Mysql(c) => run!(&mut ***c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
|
||||
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
|
||||
Held::SqlServer(c) => {
|
||||
// Tiberius has no attention/cancel API; stop reading and discard the connection.
|
||||
tokio::select! {
|
||||
@@ -1294,7 +1016,6 @@ impl Held {
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
self.after_statement(sql);
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(QueryResult {
|
||||
@@ -1334,7 +1055,7 @@ mod tests {
|
||||
#[test]
|
||||
fn discarded_sqlserver_connections_are_broken() {
|
||||
let manager = MssqlConnectionManager(tiberius::Config::new());
|
||||
assert!(manager.has_broken(&mut MssqlConnection(None, Context::new(None))));
|
||||
assert!(manager.has_broken(&mut MssqlConnection(None)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1470,19 +1191,9 @@ mod tests {
|
||||
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
|
||||
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "0")], ..s.clone() }).is_err());
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
|
||||
}
|
||||
|
||||
#[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() };
|
||||
|
||||
+26
-116
@@ -7,7 +7,6 @@ pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
@@ -57,7 +56,6 @@ impl DbTableKind {
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Package,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
@@ -93,21 +91,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 +295,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);
|
||||
@@ -326,7 +315,7 @@ pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
async fn end_session(session: &Session) {
|
||||
let held = session.lock().await.take();
|
||||
if let Some(mut conn) = held
|
||||
&& (conn.command("ROLLBACK").await.is_err() || conn.restore_default().await.is_err())
|
||||
&& conn.command("ROLLBACK").await.is_err()
|
||||
{
|
||||
conn.discard().await;
|
||||
}
|
||||
@@ -392,9 +381,7 @@ impl DbManager {
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
|
||||
store
|
||||
}
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
@@ -425,28 +412,19 @@ impl DbManager {
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
|
||||
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
|
||||
}
|
||||
let old = {
|
||||
let _guard = plugin::EDIT.lock();
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
None if !global.is_empty() => {
|
||||
return Err(IdeError::invalid("global data sources are not available here"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
old
|
||||
};
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
@@ -726,15 +704,8 @@ impl DbManager {
|
||||
let introspection = self.source(id)?.introspection;
|
||||
let system = introspection.show_system;
|
||||
let pool = self.pool(id).await?;
|
||||
if pool.driver() == DbDriver::SqlServer {
|
||||
let first =
|
||||
|rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
|
||||
let current = first(pool.rows("SELECT DB_NAME()", 1).await?).next().unwrap_or_default();
|
||||
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
|
||||
return Ok(Databases { current, names });
|
||||
}
|
||||
if pool.driver() != DbDriver::Postgres {
|
||||
return Err(IdeError::invalid("this data source does not list databases"));
|
||||
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
|
||||
}
|
||||
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
|
||||
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
|
||||
@@ -863,14 +834,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 +851,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
|
||||
@@ -1095,13 +1038,11 @@ impl DbManager {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
self.execute_from(id, statement, 0, max_rows, cancel, None).await
|
||||
self.execute_from(id, statement, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
|
||||
/// them: a later page of a result, where only that page is decoded and returned.
|
||||
/// `schema` is a query console's active context: the connection that runs the
|
||||
/// statement is put in it first, so unqualified names resolve there.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -1109,9 +1050,8 @@ impl DbManager {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
schema: Option<&str>,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, skip, max_rows, cancel, schema).await;
|
||||
let r = self.run(id, statement, skip, max_rows, cancel).await;
|
||||
let ran = match &r {
|
||||
Ok(_) => true,
|
||||
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
|
||||
@@ -1143,15 +1083,8 @@ impl DbManager {
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The execution plan of `statement` (explained, not run), in the console's
|
||||
/// active `schema` when it has one.
|
||||
pub async fn explain(
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
cancel: &CancellationToken,
|
||||
schema: Option<&str>,
|
||||
) -> IdeResult<Vec<PlanNode>> {
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
if driver == DbDriver::Oracle {
|
||||
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
|
||||
@@ -1169,16 +1102,14 @@ impl DbManager {
|
||||
0,
|
||||
MAX_META_ROWS,
|
||||
cancel,
|
||||
schema,
|
||||
)
|
||||
.await?;
|
||||
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel, None).await?;
|
||||
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
|
||||
let nodes = plan::parse(driver, &result);
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
|
||||
return nodes;
|
||||
}
|
||||
let result =
|
||||
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1189,25 +1120,11 @@ impl DbManager {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
schema: Option<&str>,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let pool = self.pool(id).await?;
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else {
|
||||
if schema.is_none() {
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
}
|
||||
// Like `Pool::execute_from`, with the schema applied to the connection first.
|
||||
let mut conn = Held::acquire(&pool).await?;
|
||||
let r = match conn.apply_context(schema).await {
|
||||
Ok(()) => conn.execute_from(&pool, statement, skip, max_rows, cancel).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
return r;
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
@@ -1215,12 +1132,10 @@ impl DbManager {
|
||||
let mut held = session.lock().await;
|
||||
if held.is_none() {
|
||||
let mut conn = Held::acquire(&pool).await?;
|
||||
conn.apply_context(schema).await?;
|
||||
conn.command(conn::begin_statement(pool.driver())).await?;
|
||||
*held = Some(conn);
|
||||
}
|
||||
let conn = held.as_mut().expect("opened above");
|
||||
conn.apply_context(schema).await?;
|
||||
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
// The server rolls the transaction back with the connection.
|
||||
@@ -1256,12 +1171,7 @@ impl DbManager {
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
let Some(mut conn) = held.take() else { return Ok(()) };
|
||||
let result = async {
|
||||
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
|
||||
conn.restore_default().await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
conn.discard().await;
|
||||
|
||||
+244
-616
File diff suppressed because it is too large.
Load diff
Loaded 100 of 2652 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user