Compare commits

..
Author SHA1 Message Date
l0ng-ai 37414d4257 test(mcp): expect the platform's line separator and shell on Windows 2026-11-08 22:43:50 +08:00
l0ng-ai 04ab275fe2 fix(mcp): list run configurations without asking and build on Windows
Listing configurations doesn't run anything, so it no longer opens the
confirmation; the run confirmation leaves out an empty working directory.
The Windows build no longer warns about an import only Unix uses.
2026-11-08 22:43:50 +08:00
2bc10df2a9 feat(mcp): add editing, execution confirmations and Git tools
Apply file changes through IDE documents and workspace edits, expose language
references and rename, and ask in the project window before run and build tools.

Co-authored-by: haruka <[email protected]>
Co-authored-by: imacross <[email protected]>
2026-11-08 22:43:50 +08:00
161d9cba41 feat(mcp): add built-in read-only IDE tools
Serve project files, search, symbols and window snapshots through an opt-in
local MCP server, with client configurations and ACP session integration.

Co-authored-by: haruka <[email protected]>
Co-authored-by: imacross <[email protected]>
2026-11-08 22:43:50 +08:00
1529 changed files with 11549 additions and 110983 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release-build.yml):
# The remote development server ships as a static Linux binary (release.yml):
# keep that build working.
server:
name: Remote server (linux musl)
+2 -17
View File
@@ -47,7 +47,6 @@ env:
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -55,9 +54,8 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -76,12 +74,6 @@ jobs:
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
- if: matrix.language == 'c' || matrix.language == 'cpp'
run: |
sudo apt-get update
sudo apt-get install -y gdb
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
@@ -116,13 +108,6 @@ jobs:
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
- if: matrix.language == 'php'
uses: shivammathur/setup-php@v2
with: { php-version: "8.5", coverage: xdebug, tools: composer }
- if: matrix.language == 'php'
run: npm install --global "intelephense@$INTELEPHENSE"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
-119
View File
@@ -1,119 +0,0 @@
name: Nightly
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
# version, so remote development finds the server of that version. The `nightly` release holds
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
# nothing newer stops there. Only the latest builds are kept.
on:
schedule:
- cron: "17 2,14 * * *"
workflow_dispatch:
inputs:
force:
description: "Build even if nothing is newer than the last nightly"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
jobs:
prepare:
permissions:
actions: read
contents: write
if: github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.commit.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
release_id: ${{ steps.release.outputs.id }}
steps:
- id: commit
env:
FORCE: ${{ inputs.force }}
run: |
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
--json headSha --jq '.[0].headSha // ""')"
if [ -z "$sha" ]; then
echo "::notice::no main commit has passed CI"
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
if: steps.commit.outputs.sha != ''
with: { ref: "${{ steps.commit.outputs.sha }}" }
- uses: actions/setup-node@v5
if: steps.commit.outputs.sha != ''
with: { node-version: 22 }
- uses: dtolnay/rust-toolchain@stable
if: steps.commit.outputs.sha != ''
- id: release
if: steps.commit.outputs.sha != ''
env:
SHA: ${{ steps.commit.outputs.sha }}
run: |
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
tag="v$version"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: $tag"
git tag "$tag"
git push origin "$tag"
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "id=$id" >> "$GITHUB_OUTPUT"
binaries:
permissions:
contents: write
needs: prepare
if: needs.prepare.outputs.tag != ''
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
release_id: ${{ needs.prepare.outputs.release_id }}
secrets: inherit
publish:
permissions:
contents: write
needs: [prepare, binaries]
runs-on: ubuntu-latest
env:
TAG: ${{ needs.prepare.outputs.tag }}
SHA: ${{ needs.prepare.outputs.sha }}
KEEP: 14
steps:
- name: Publish the build
run: gh release edit "$TAG" --tag "$TAG" --draft=false
- name: Point the updater at it
run: |
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
gh release view nightly >/dev/null 2>&1 ||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
--notes "The update manifest of the nightly builds, and the commit last built."
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
# Failed builds' drafts go too, once they are this old.
- name: Delete old builds
run: |
gh release list --limit 200 --json tagName,createdAt --jq \
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
while read -r tag; do
gh release delete "$tag" --cleanup-tag --yes
done
-2
View File
@@ -22,8 +22,6 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
-262
View File
@@ -1,262 +0,0 @@
name: Release build
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
release_id: { type: string, required: true }
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
TAG: ${{ inputs.tag }}
jobs:
plugin-keys:
permissions: {}
runs-on: ubuntu-latest
outputs:
keys: ${{ steps.keys.outputs.keys }}
steps:
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
build:
permissions:
contents: write
needs: plugin-keys
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ inputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
# Release assets have no spaces: GitHub stored the first upload with dots.
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
cp "$dmg" "$asset"
gh release upload "$TAG" "$asset" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
permissions:
contents: write
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ inputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
const nightly = tag.includes("-nightly.");
const windows = (arch, msi, nsis) => nightly
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
+229 -24
View File
@@ -1,7 +1,7 @@
name: Release
on:
push:
tags: ["v*", "!v*-nightly.*"]
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
@@ -9,7 +9,7 @@ on:
required: true
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
@@ -23,12 +23,10 @@ env:
jobs:
# Creates (or reuses) one draft release that every platform build uploads into.
create-release:
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
@@ -49,6 +47,20 @@ jobs:
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: plugin-keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
with:
@@ -62,26 +74,161 @@ jobs:
core.setOutput("id", existing.id);
return;
}
// Nightly builds are prereleases: the notes span stable release to stable release.
const previous = releases.find((r) => !r.draft && !r.prerelease);
const { data: notes } = await github.rest.repos.generateReleaseNotes({
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
});
const { data } = await github.rest.repos.createRelease({
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
draft: true, prerelease: tag.includes("-"), body: notes.body,
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
});
core.setOutput("id", data.id);
binaries:
permissions:
contents: write
build:
needs: create-release
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ github.event.inputs.tag || github.ref_name }}
release_id: ${{ needs.create-release.outputs.release_id }}
secrets: inherit
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
needs: create-release
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
@@ -93,8 +240,6 @@ jobs:
# openidea-plugin tool for Linux x64, which that workflow and the registry's
# checks run.
plugins:
permissions:
contents: write
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
@@ -158,10 +303,70 @@ jobs:
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
"$out/openidea-plugin-linux-x86_64.gz" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
permissions:
contents: write
needs: [create-release, binaries, plugins]
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"plugins/themes.md",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+1 -3
View File
@@ -21,13 +21,11 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
## Rules
Generated
+62 -218
View File
@@ -60,20 +60,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -630,12 +616,6 @@ dependencies = [
"cipher",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
@@ -710,12 +690,6 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -1522,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -2056,15 +2030,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
@@ -2222,17 +2187,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "faster-hex"
version = "0.10.1"
@@ -2333,17 +2287,6 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "fluent-uri"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "flume"
version = "0.12.0"
@@ -2403,16 +2346,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fraction"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
dependencies = [
"lazy_static",
"num",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
@@ -4180,7 +4113,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4204,7 +4137,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4218,7 +4151,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4229,13 +4162,11 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"chardetng",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-core",
"serde",
"serde_json",
@@ -4247,7 +4178,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4261,9 +4192,8 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4271,7 +4201,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4288,7 +4218,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4302,7 +4232,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4323,12 +4253,10 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"bb8",
"blake3",
"dirs 6.0.0",
"dunce",
"futures",
"ide-core",
"ide-http",
@@ -4346,14 +4274,13 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
]
[[package]]
name = "ide-docker"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"serde",
@@ -4366,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4386,7 +4313,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4398,7 +4325,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4411,7 +4338,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4453,7 +4380,6 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"ignore",
"jsonschema",
"parking_lot",
"quick-xml 0.42.0",
"regex-lite",
@@ -4477,7 +4403,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4495,7 +4421,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4517,7 +4443,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"encoding_rs",
"futures",
@@ -4534,7 +4460,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"criterion",
@@ -4590,7 +4516,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"futures",
@@ -4616,7 +4542,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"bytes",
"dunce",
@@ -4635,7 +4561,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"futures",
"ide-core",
@@ -4658,10 +4584,9 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
"ide-core",
"ide-extension",
"parking_lot",
@@ -4673,7 +4598,6 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tracing",
"url",
"wasmtime",
"wasmtime-wasi",
"wat",
@@ -4681,7 +4605,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-run",
@@ -4694,9 +4618,8 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"globset",
"ide-core",
"ide-run",
@@ -4713,7 +4636,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"encoding_rs",
@@ -4737,7 +4660,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dunce",
"ide-core",
@@ -4745,7 +4668,6 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
@@ -4757,7 +4679,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"criterion",
"globset",
@@ -4777,7 +4699,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"parking_lot",
@@ -4790,7 +4712,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4811,7 +4733,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"ide-language",
@@ -4826,7 +4748,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"libc",
@@ -4839,7 +4761,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"criterion",
"ide-core",
@@ -4852,7 +4774,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"flate2",
"hex",
@@ -4873,7 +4795,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -4894,7 +4816,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"chardetng",
"dunce",
@@ -4918,7 +4840,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"ide-core",
"open",
@@ -4934,7 +4856,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"blake3",
"dunce",
@@ -5365,32 +5287,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
dependencies = [
"ahash",
"base64 0.22.1",
"bytecount",
"email_address",
"fancy-regex",
"fraction",
"idna",
"itoa",
"num-cmp",
"num-traits",
"once_cell",
"percent-encoding",
"referencing",
"regex",
"regex-syntax",
"serde",
"serde_json",
"uuid-simd",
]
[[package]]
name = "jupyter-protocol"
version = "2.0.3"
@@ -5581,9 +5477,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
@@ -5624,7 +5520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
dependencies = [
"bitflags 1.3.2",
"fluent-uri 0.1.4",
"fluent-uri",
"serde",
"serde_json",
"serde_repr",
@@ -5966,12 +5862,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -6363,15 +6253,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.2.2"
@@ -6521,7 +6402,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.40"
version = "0.1.39"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6658,12 +6539,6 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.14.0"
@@ -7431,20 +7306,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "referencing"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
dependencies = [
"ahash",
"fluent-uri 0.3.2",
"once_cell",
"parking_lot",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regalloc2"
version = "0.15.2"
@@ -8638,7 +8499,8 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlx"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -8650,7 +8512,8 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -8672,7 +8535,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.10.9",
"smallvec",
"thiserror 2.0.21",
"tokio",
@@ -8685,7 +8548,8 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
dependencies = [
"proc-macro2",
"quote",
@@ -8697,7 +8561,8 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
dependencies = [
"cfg-if",
"dotenvy",
@@ -8708,7 +8573,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8721,7 +8586,8 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
dependencies = [
"bitflags 2.13.2",
"byteorder",
@@ -8746,7 +8612,8 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
"base64 0.22.1",
@@ -8780,7 +8647,8 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.9.0"
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
dependencies = [
"atoi",
"flume",
@@ -9545,7 +9413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -10628,17 +10496,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"uuid",
"vsimd",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -10663,12 +10520,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -11311,12 +11162,6 @@ name = "whoami"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
dependencies = [
"libc",
"libredox",
"objc2-system-configuration",
"web-sys",
]
[[package]]
name = "win_uds"
@@ -12152,15 +11997,14 @@ dependencies = [
[[package]]
name = "zip"
version = "9.2.3"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00fb4744e923442ecadfd3f85f51a5d24e03bf43d53813b81ebb4c91d02baa62"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap 2.14.2",
"libc",
"memchr",
"nix 0.31.3",
"typed-path",
+4 -7
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.40"
version = "0.1.39"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -82,7 +82,7 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
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"
@@ -103,8 +103,7 @@ portable-pty = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
@@ -132,9 +131,7 @@ debug = false
[profile.release]
codegen-units = 1
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
lto = true
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+10 -6
View File
@@ -12,8 +12,6 @@
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh">Docs</a>
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
@@ -27,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
@@ -56,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
@@ -64,9 +64,13 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
### Contributors
<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
+2 -6
View File
@@ -262,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
@@ -290,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
+7 -42
View File
@@ -2,8 +2,7 @@
//! the agent reports them over ACP.
use crate::model::{
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
AgentUsage,
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
};
use serde_json::{Value, json};
@@ -146,22 +145,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),
})
@@ -333,39 +325,12 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
{"name": "review", "source": "skill"},
{"name": "$lint"},
{"name": "compact"}
]});
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
assert_eq!(
sources("codex-acp"),
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
);
assert_eq!(
sources("custom"),
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
);
let old: AgentCommand =
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
assert_eq!(old.source, AgentCommandSource::Command);
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(
&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}),
"claude",
);
assert_eq!(cmds[0].source, AgentCommandSource::Command);
let cmds = commands_of(&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}));
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
+30 -187
View File
@@ -100,8 +100,6 @@ struct Shared {
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
probes: Mutex<HashMap<String, AgentProbe>>,
/// One pre-session lookup of commands and settings per provider, including failed attempts.
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
idle_release_ms: AtomicU64,
}
@@ -153,46 +151,6 @@ impl Shared {
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
}
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
let mut ready = {
let mut fetches = self.command_fetches.lock();
fetches
.entry(spec.id.clone())
.or_insert_with(|| {
let (done, ready) = tokio::sync::watch::channel(false);
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
if let Ok((commands, options)) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": options }));
let _ = tokio::task::spawn_blocking(move || {
// Notifies subscribers itself when the discovery
// changed the cached config; unchanged results
// stay silent.
shared.remember_discovered(&spec.id, &options, &commands);
})
.await;
}
let _ = done.send(true);
});
ready
})
.clone()
};
while !*ready.borrow_and_update() {
if ready.changed().await.is_err() {
break;
}
}
}
fn has_config(&self, provider: &str) -> bool {
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
}
fn root_key(&self) -> String {
self.paths.project_root.to_string_lossy().into_owned()
}
@@ -205,63 +163,41 @@ impl Shared {
commands: Option<&[AgentCommand]>,
model: Option<&str>,
) {
self.remember_inner(provider, config, commands, model, false);
}
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
}
fn remember_inner(
&self,
provider: &str,
config: Option<&[AgentConfigOption]>,
commands: Option<&[AgentCommand]>,
model: Option<&str>,
discovered: bool,
) {
// Keep the update and its disk write ordered; a session report wins over discovery.
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
let snapshot = {
let mut cache = self.known.lock();
let known = cache.entry(provider.to_owned()).or_default();
let mut changed = false;
if let Some(c) = config {
if let Some(model) = model {
let levels: Vec<_> =
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
if known.thought_levels.get(model) != Some(&levels) {
known.thought_levels.insert(model.to_owned(), levels);
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
changed = true;
}
}
if !c.is_empty() && known.config != c {
known.config = c.to_vec();
let root = self.root_key();
if let Some(c) =
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
{
known.commands.insert(root, c.to_vec());
changed = true;
}
}
let root = self.root_key();
if let Some(c) = commands.filter(|c| {
!c.is_empty()
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
}) {
known.commands.insert(root, c.to_vec());
changed = true;
}
if !changed {
return;
}
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
if !changed {
return;
}
serde_json::to_vec_pretty(&*cache)
};
if let Ok(bytes) = snapshot
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
{
tracing::warn!("saving agent settings: {e}");
}
// The cached provider config actually changed (a session report, discovery,
// or a turn update): tell subscribers to re-list, so a changed model list
// reaches the composer without waiting for the next task (#1490). Unchanged
// reports stay silent, so routine session traffic emits nothing. The cache
// lock is released first; the sink never runs under it.
drop(cache);
self.emit(AgentEvent::Providers);
}
}
@@ -345,8 +281,6 @@ struct Task {
/// Settings already sent this session (`(option, value)`), so a refusal is not retried.
config_sent: Mutex<HashSet<(String, String)>>,
dirty: Mutex<BTreeSet<(Session, usize)>>,
/// How much of each session's streaming message was last sent, so the next flush sends the rest.
streamed: Mutex<HashMap<Session, Sent>>,
flush_scheduled: AtomicBool,
stderr: Mutex<VecDeque<String>>,
/// Set by `delete`; held while saving.
@@ -383,7 +317,6 @@ impl AgentManager {
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
command_fetches: Mutex::new(HashMap::new()),
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
});
let mut tasks = HashMap::new();
@@ -487,21 +420,9 @@ impl AgentManager {
// Commands the agent implements itself and never lists (its CLI builtins):
// merged without duplicating what `availableCommands` or the disk offer.
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
self.shared.fetch_settings(spec).await;
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
offered.push(AgentCommand {
name: name.to_owned(),
description: description.to_owned(),
hint: None,
source: AgentCommandSource::Command,
});
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
}
}
if provider != "claude" && provider != "claude-acp" {
@@ -854,7 +775,6 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: 0 });
task.emit_task();
@@ -880,7 +800,6 @@ impl AgentManager {
s.task.updated_ms = now_ms();
first
};
task.streamed.lock().clear();
task.persist();
self.shared.emit(AgentEvent::Truncated { task: id.to_owned(), length: cut as u32 });
task.emit_task();
@@ -1133,7 +1052,6 @@ impl Task {
idle_epoch: AtomicU64::new(0),
config_sent: Mutex::new(HashSet::new()),
dirty: Mutex::new(BTreeSet::new()),
streamed: Mutex::new(HashMap::new()),
flush_scheduled: AtomicBool::new(false),
stderr: Mutex::new(VecDeque::new()),
deleted: Mutex::new(false),
@@ -1255,12 +1173,11 @@ impl Task {
let indices = std::mem::take(&mut *self.dirty.lock());
let events: Vec<AgentEvent> = {
let s = self.state.lock();
let mut streamed = self.streamed.lock();
indices
.into_iter()
.filter_map(|(subagent, i)| {
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?;
Some(entry_event(&self.id, &mut streamed, subagent, i, entry))
let entry = s.conversation.timeline(&subagent)?.entries.get(i)?.clone();
Some(AgentEvent::Entry { task: self.id.clone(), subagent, index: i as u32, entry })
})
.collect()
};
@@ -1795,7 +1712,7 @@ impl Task {
true
}
"available_commands_update" => {
s.task.commands = config::commands_of(update, &provider);
s.task.commands = config::commands_of(update);
true
}
"usage_update" => {
@@ -2322,77 +2239,3 @@ fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root
let command = ide_mcp::stdio_command(executable, root);
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
}
/// What was sent of a streaming message: its first `bytes` (`utf16` UTF-16 units), which
/// ended with `tail`. A message only grows while it streams, so its tail tells a message
/// that went on from one that was replaced without comparing (or keeping) all of it.
struct Sent {
index: usize,
bytes: usize,
utf16: usize,
tail: String,
}
const SENT_TAIL: usize = 64;
impl Sent {
fn new(index: usize, text: &str) -> Self {
Self::after(index, text, 0, 0)
}
/// `text` as sent, of which the first `bytes` (`utf16` units) were sent before.
fn after(index: usize, text: &str, bytes: usize, utf16: usize) -> Self {
let mut start = text.len().saturating_sub(SENT_TAIL);
while !text.is_char_boundary(start) {
start -= 1;
}
let utf16 = utf16 + text[bytes..].encode_utf16().count();
Self { index, bytes: text.len(), utf16, tail: text[start..].to_owned() }
}
/// Whether `text` at `index` is this message, gone on.
fn extended_by(&self, index: usize, text: &str) -> bool {
self.index == index
&& text.len() > self.bytes
&& text.is_char_boundary(self.bytes)
&& text[..self.bytes].ends_with(&self.tail)
}
}
/// The event that brings the UI's copy of entry `i` up to date: only the new text when
/// it extends the message last sent for the session, else the whole entry.
fn entry_event(
task: &str,
streamed: &mut HashMap<Session, Sent>,
subagent: Session,
i: usize,
entry: &AgentEntry,
) -> AgentEvent {
let text = match entry {
AgentEntry::Agent { text } | AgentEntry::Thought { text } => Some(text),
_ => None,
};
let Some(text) = text else {
if streamed.get(&subagent).is_some_and(|sent| sent.index == i) {
streamed.remove(&subagent);
}
return AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() };
};
match streamed.get(&subagent).filter(|sent| sent.extended_by(i, text)) {
Some(sent) => {
let (bytes, from) = (sent.bytes, sent.utf16);
streamed.insert(subagent.clone(), Sent::after(i, text, bytes, from));
AgentEvent::Append {
task: task.to_owned(),
subagent,
index: i as u32,
from: from as u32,
text: text[bytes..].to_owned(),
}
}
None => {
streamed.insert(subagent.clone(), Sent::new(i, text));
AgentEvent::Entry { task: task.to_owned(), subagent, index: i as u32, entry: entry.clone() }
}
}
}
-19
View File
@@ -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)]
@@ -518,15 +508,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,
-57
View File
@@ -69,63 +69,6 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let mut lines = BufReader::new(io.stdout).lines();
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok((settings.commands.clone(), settings.options()))
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
+1 -37
View File
@@ -53,7 +53,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 +82,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)]
@@ -303,20 +300,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);
@@ -343,7 +326,6 @@ impl CodexShim {
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 }))
}
@@ -362,7 +344,6 @@ impl CodexShim {
for update in translate::history(thread, cwd.as_deref()) {
self.emit(id, update);
}
self.publish_skills(id).await;
Ok(json!({ "configOptions": options }))
}
@@ -381,7 +362,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 +551,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);
+6 -129
View File
@@ -528,21 +528,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}");
@@ -1065,117 +1056,3 @@ fn session_mcp_servers_become_thread_configuration() {
)
);
}
#[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();
-1
View File
@@ -64,7 +64,6 @@ const BUILTIN: &[(&str, &str, Driver, &[&str], &str)] = &[
("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> {
+9 -32
View File
@@ -2,7 +2,7 @@
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
//! of the working directory and every directory above it, then the user's `~/.claude`.
use crate::{AgentCommand, AgentCommandSource};
use crate::AgentCommand;
use std::collections::HashSet;
use std::path::Path;
@@ -36,14 +36,9 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
/// `first` (the agent's own list) plus the commands of `more` it lacks.
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
let mut out = first;
for c in more {
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
offered.source = c.source;
} else {
out.push(c);
}
}
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
out
}
@@ -66,7 +61,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
return None;
}
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
Some(meta.command(name, &text, AgentCommandSource::Skill))
Some(meta.command(name, &text))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -84,7 +79,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
} else if let Some(stem) = name.strip_suffix(".md")
&& let Ok(text) = std::fs::read_to_string(&path)
{
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
}
}
}
@@ -152,7 +147,7 @@ impl Frontmatter {
}
/// `description`, else the body's first line.
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
fn command(&self, name: String, text: &str) -> AgentCommand {
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
let body = text.get(self.body..).unwrap_or_default();
body.lines()
@@ -163,7 +158,6 @@ impl Frontmatter {
});
AgentCommand {
name,
source,
description: truncate(&description),
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
}
@@ -200,20 +194,13 @@ mod tests {
}
fn cmd(name: &str, description: &str) -> AgentCommand {
AgentCommand {
name: name.into(),
description: description.into(),
hint: None,
source: AgentCommandSource::Command,
}
AgentCommand { name: name.into(), description: description.into(), hint: None }
}
#[test]
fn project_and_user_skills_and_commands() {
let tmp = tempfile::tempdir().unwrap();
// Home is an ancestor of the project, so the walk stops there instead of climbing
// past it into the real profile (a temp dir under the user's home holds its skills).
let home = tmp.path().to_path_buf();
let home = tmp.path().join("home");
let repo = tmp.path().join("work").join("idea");
let skills = repo.join(".claude/skills");
write(
@@ -240,12 +227,6 @@ mod tests {
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
);
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
for name in ["issue-fix", "triage", "verify", "lint"] {
assert_eq!(by(name).source, AgentCommandSource::Skill);
}
for name in ["git:commit", "review", "parent", "brooks-review"] {
assert_eq!(by(name).source, AgentCommandSource::Command);
}
assert_eq!(by("triage").description, "One pass over open issues and PRs");
assert_eq!(by("issue-fix").description, "Fix an issue");
assert_eq!(by("verify").description, "Build and drive the app");
@@ -281,14 +262,10 @@ mod tests {
fn the_agents_entries_win() {
let merged = merge(
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
vec![
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
cmd("verify", "Verify"),
],
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
);
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
assert_eq!(merged[0].description, "from the agent");
assert_eq!(merged[0].source, AgentCommandSource::Skill);
}
#[test]
+2 -301
View File
@@ -339,9 +339,6 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
}
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
let agents = dir.join("agents");
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
let (a, b) = duplex(1 << 20);
let (ar, aw) = tokio::io::split(a);
@@ -356,200 +353,14 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
store_dir: dir.join("store"),
worktrees_dir: dir.join("worktrees"),
models_file: models_file.to_path_buf(),
custom_agents_dir: Some(agents),
// The project is the boundary of the skill walk: nothing above it (such as the
// user's profile, when temp lives under it) leaks into the test.
home_dir: Some(dir.to_path_buf()),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
)
}
#[tokio::test(flavor = "multi_thread")]
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Review the saved conversation"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.shutdown();
let restored_agent = Arc::new(FakeAgent::default());
let restored = manager(tmp.path(), restored_agent.clone());
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
assert!(restored_agent.calls.lock().is_empty());
assert!(restored.entries("missing-task", None).is_err());
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
use std::os::unix::fs::PermissionsExt;
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let script = root.join("fake-claude");
std::fs::write(
&script,
r#"#!/usr/bin/env python3
import json, os, pathlib, sys, time
root = pathlib.Path.cwd()
(root / 'pid').write_text(str(os.getpid()))
with (root / 'launches').open('a') as f:
f.write(json.dumps(sys.argv[1:]) + '\n')
request = json.loads(sys.stdin.readline())
with (root / 'requests').open('a') as f:
f.write(json.dumps(request) + '\n')
outcome = os.environ['OUTCOME']
if outcome == 'exit':
sys.exit(1)
if outcome == 'timeout':
time.sleep(60)
response = {'subtype': outcome, 'request_id': request['request_id']}
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
response['subtype'] = 'success'
response['response'] = {'commands': [
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
'models': [
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
if outcome == 'empty':
response['response'] = {}
else:
response['error'] = 'Authentication required'
if outcome == 'session_report':
(root / 'initialized').touch()
while not (root / 'continue').exists():
time.sleep(0.01)
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
for line in sys.stdin:
with (root / 'requests').open('a') as f:
f.write(line)
request = json.loads(line)
if request['request']['subtype'] == 'get_settings':
if outcome == 'settings_timeout':
time.sleep(60)
reply = {'subtype': 'success', 'request_id': request['request_id'],
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
"#,
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap();
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
.unwrap();
let mgr = manager(root, Arc::new(FakeAgent::default()));
{
let mut providers = mgr.shared.providers.write();
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
if outcome != "missing" {
spec.command = script.to_string_lossy().into_owned();
}
spec.env = vec![("OUTCOME".into(), outcome.into())];
}
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(2), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
std::fs::write(root.join("continue"), "").unwrap();
}
mgr.commands("claude", None).await
};
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
let first = first.unwrap();
assert_eq!(first, concurrent.unwrap());
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
let restored = manager(root, Arc::new(FakeAgent::default()));
assert_eq!(first, restored.commands("claude", None).await.unwrap());
// The same handshake lists the models, so the composer has them before any task.
for mgr in [&mgr, &restored] {
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
assert_eq!(model.current.as_deref(), Some("default"));
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus"]);
let effort =
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
assert_eq!(
effort.unwrap().current.as_deref(),
Some(match outcome {
"settings_timeout" => "default",
"session_report" => "low",
_ => "high",
})
);
}
} else {
assert_eq!(names, ["triage"], "{outcome}");
assert_eq!(root.join("models.json").exists(), outcome == "empty");
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
}
assert!(mgr.tasks.read().is_empty());
assert!(!root.join("store").exists());
if outcome != "missing" {
let launches = std::fs::read_to_string(root.join("launches")).unwrap();
assert_eq!(launches.lines().count(), 1, "{outcome}");
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
let subtypes: Vec<String> = requests
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
.collect();
// The handshake and the settings read: no prompt or other control request.
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
&["initialize", "get_settings"]
} else {
&["initialize"]
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("discovery process must exit");
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
for auth_on_new in [true, false] {
@@ -764,7 +575,6 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada
name: "compact".into(),
description: "Agent supplied description".into(),
hint: Some("instructions".into()),
source: AgentCommandSource::Command,
};
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
// The task's provider wins over the caller's provider; metadata is not replaced.
@@ -2044,7 +1854,6 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
String::from_utf8_lossy(&out.stdout).trim().to_owned()
};
git(&["init", "-q", "-b", "member-main"]);
git(&["config", "core.autocrlf", "false"]);
std::fs::write(member.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
@@ -2175,49 +1984,6 @@ async fn reload_providers_updates_registry_install_clears_stale_probes_and_notif
assert!(probe.error.is_none());
}
/// A session report that changes the cached provider config notifies subscribers,
/// so the composer re-lists models without waiting for the next task (#1490).
/// Reports that change nothing stay silent.
#[tokio::test(flavor = "multi_thread")]
async fn remembered_config_changes_notify_subscribers_once() {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let events = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
mgr.subscribe(Arc::new(move |e| sink.lock().push(e)));
events.lock().clear();
let reported = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
] }));
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a first report changing the cache notifies subscribers"
);
events.lock().clear();
// The same report again: the cache is unchanged, so subscribers hear nothing.
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
assert!(events.lock().is_empty(), "an unchanged report must stay silent");
// A changed model list notifies again, and providers() serves the new list.
let updated = config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
"options": [{ "value": "default" }, { "value": "custom-opus" }, { "value": "custom-sonnet" }] },
] }));
mgr.shared.remember("claude", Some(&updated), None, Some("default"));
assert!(
events.lock().iter().any(|ev| matches!(ev, AgentEvent::Providers)),
"a changed model list notifies subscribers"
);
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
assert_eq!(values, ["default", "custom-opus", "custom-sonnet"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
let new = || NewAgentTask {
@@ -2401,7 +2167,6 @@ async fn delete_running_agent(no_load: bool) {
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "core.autocrlf", "false"]);
std::fs::write(root.join("file.txt"), "original\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "initial"]);
@@ -2504,67 +2269,3 @@ fn mcp_servers_follow_server_state_and_agent_capabilities() {
"args":["--mcp-stdio","--project","/project"],"env":[]})]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
for discovery_first in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
let options = |model: &str, effort: &str| {
config::options_of(&json!({ "configOptions": [
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
"options": [{ "value": "saved" }, { "value": "discovered" }] },
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
"options": [{ "value": "low" }, { "value": "high" }] }
] }))
};
let saved = options("saved", "low");
let discovered = options("discovered", "high");
if discovery_first {
mgr.shared.remember_discovered("claude", &discovered, &[]);
}
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
mgr.shared.remember_discovered("claude", &discovered, &[]);
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
for mgr in [&mgr, &restored] {
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
}
}
}
#[test]
fn streamed_text_is_sent_as_appends() {
let mut streamed = HashMap::new();
let agent = |t: &str| AgentEntry::Agent { text: t.into() };
let first = entry_event("t", &mut streamed, None, 3, &agent("Hé"));
assert!(matches!(first, AgentEvent::Entry { index: 3, .. }), "the first sight of a message is whole");
let next = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there"));
assert!(
matches!(&next, AgentEvent::Append { subagent: None, index: 3, from: 2, text, .. } if text == " 👋 there"),
"{next:?}"
);
let sub = entry_event("t", &mut streamed, Some("s".into()), 3, &agent("Hé 👋 there!"));
assert!(matches!(sub, AgentEvent::Entry { .. }), "each session streams on its own");
let after = entry_event("t", &mut streamed, None, 3, &agent("Hé 👋 there."));
assert!(
matches!(&after, AgentEvent::Append { from: 11, text, .. } if text == "."),
"from counts UTF-16 units: {after:?}"
);
let replaced = entry_event("t", &mut streamed, None, 3, &agent("Other"));
assert!(matches!(replaced, AgentEvent::Entry { .. }), "text that doesn't extend the sent text is sent whole");
let thought = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm…".into() });
assert!(matches!(thought, AgentEvent::Entry { index: 4, .. }), "a new message starts whole");
let tool = AgentEntry::Error { message: "x".into() };
entry_event("t", &mut streamed, None, 4, &tool);
let again = entry_event("t", &mut streamed, None, 4, &AgentEntry::Thought { text: "hmm… ok".into() });
assert!(matches!(again, AgentEvent::Entry { .. }), "an entry replaced by another kind forgets its text");
let long = "é".repeat(5000);
entry_event("t", &mut streamed, None, 5, &agent(&long));
assert!(streamed[&None].tail.len() <= 64, "only the end of what was sent is kept");
let on = entry_event("t", &mut streamed, None, 5, &agent(&format!("{long}!")));
assert!(matches!(&on, AgentEvent::Append { from: 5000, text, .. } if text == "!"), "{on:?}");
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
}
-2
View File
@@ -7,9 +7,7 @@ license.workspace = true
[dependencies]
blake3.workspace = true
chardetng.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
+1 -79
View File
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
@@ -184,30 +184,6 @@ pub fn launch_fingerprint(
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
impl LaunchFingerprint {
/// Relative paths whose content changed while `(length, mtime)` stayed the same
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
/// re-stamp the returned paths before building. Inputs without a content hash
/// (directories, files outside the project) are never reported.
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
let mut edits = Vec::new();
for (path, current_stamp) in &current.files {
let Some(current_stamp) = current_stamp else { continue };
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
edits.push(path.clone());
}
}
edits.sort();
edits
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -306,60 +282,6 @@ mod tests {
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn mtime_preserved_edits_only_reports_lying_timestamps() {
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
}
fn prints(
previous: BTreeMap<PathBuf, Option<Stamp>>,
current: BTreeMap<PathBuf, Option<Stamp>>,
) -> Vec<PathBuf> {
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
previous.mtime_preserved_edits(&current)
}
// Same length and mtime, different content: the builder would skip it.
assert_eq!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
vec![PathBuf::from("a.java")]
);
// A normal edit bumps the mtime: the builder already sees it.
let mut touched = stamp(5, "new").unwrap();
touched.1 += std::time::Duration::from_secs(1);
assert!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
);
// Added, deleted, and hash-less inputs (directories, files outside the
// project) carry no comparable content: never reported.
assert!(
prints(
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
)
.is_empty()
);
}
#[test]
fn lying_source_is_found_against_a_real_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model).unwrap();
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content, preserved mtime.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model).unwrap();
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
// A normal edit (mtime advances) needs no repair.
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
let normal = fingerprint(root, &model).unwrap();
assert!(before.mtime_preserved_edits(&normal).is_empty());
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+24 -38
View File
@@ -772,7 +772,13 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module), None)?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
@@ -784,12 +790,6 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind:
Ok(plan)
}
#[derive(Debug)]
pub struct LaunchBuild {
pub class_dirs: Vec<PathBuf>,
pub failure: Option<String>,
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -797,12 +797,11 @@ pub fn build_for_launch(
model: &BuildModel,
module: &Path,
test: bool,
kind: BuildKind,
cancel: &CancellationToken,
) -> IdeResult<LaunchBuild> {
let plan = launch_plan(root, model, module, test, kind)?;
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
let failure = if !result.success {
if !result.success {
let errors: Vec<String> = result
.messages
.iter()
@@ -813,21 +812,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> {
@@ -1314,17 +1309,13 @@ mod tests {
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
// Rebuild cleans first, as IDEA's Rebuild does.
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
assert_eq!(
plan.steps[0].args,
step.args,
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
@@ -1346,10 +1337,9 @@ mod tests {
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
.unwrap();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs.class_dirs,
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
@@ -1358,16 +1348,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
);
}
+9 -641
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
}
out
}
@@ -565,12 +565,11 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(statements) {
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -592,7 +591,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -604,354 +602,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -969,8 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -1155,7 +808,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script, &BTreeMap::new());
let deps = script_dependencies(script);
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -1169,291 +822,6 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+7 -82
View File
@@ -76,7 +76,7 @@ pub fn load_linked(
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
let profiles = sync::maven_profiles(cache_dir);
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
@@ -238,30 +222,6 @@ mod tests {
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir(root.join("extra")).unwrap();
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
)
.unwrap();
let cache = root.join("cache");
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
let modules = |settings: &ide_core::settings::MavenSettings| {
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
};
assert!(modules(&Default::default()).is_empty());
assert_eq!(modules(&settings), ["extra/pom.xml"]);
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
assert!(modules(&settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -328,39 +288,4 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
+9 -36
View File
@@ -16,8 +16,6 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
@@ -73,9 +71,6 @@ pub struct MavenModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProject {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
pub pom: String,
/// Project-relative directory; `""` = the project root.
@@ -174,9 +169,6 @@ pub enum Omission {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProfile {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
pub id: String,
pub active_by_default: bool,
}
@@ -499,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> =
@@ -511,7 +503,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
}
profiles
}
@@ -519,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() {
@@ -545,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()
}
@@ -736,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 };
@@ -1071,7 +1063,7 @@ pub fn load(root: &Path) -> MavenModel {
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1220,12 +1212,11 @@ pub fn load_with_linked(
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
}
}
let artifact_id = i(&pom.artifact_id);
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
@@ -1640,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));
@@ -1845,7 +1836,7 @@ mod tests {
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
@@ -2297,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,
-124
View File
@@ -1,124 +0,0 @@
//! On-demand sources resolution using the project's Maven launcher and repositories.
use super::MavenProject;
use crate::process;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub struct Artifact {
pub group: String,
pub name: String,
pub version: String,
pub sources: PathBuf,
}
/// Resolve only jars inside the selected local repository, including custom repositories.
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
let relative = jar.strip_prefix(repository).ok()?;
let parts: Vec<&str> = relative
.components()
.map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Option<_>>()?;
if parts.len() < 4 {
return None;
}
let n = parts.len();
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
let valid = |s: &str| {
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
};
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
return None;
}
Some(Artifact {
group: parts[..n - 3].join("."),
name: name.into(),
version: version.into(),
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
})
}
pub fn download(
root: &Path,
project: &MavenProject,
artifact: &Artifact,
repository: &Path,
cancel: &CancellationToken,
) -> IdeResult<Option<PathBuf>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
args.extend(project.options.clone());
args.extend([
format!("-Dmaven.repo.local={}", repository.display()),
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
"-Dtransitive=false".into(),
]);
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
if output.success
|| output.text.lines().any(|line| {
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
})
{
return Ok(None);
}
Err(IdeError::unavailable(
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_sources_coordinates_from_maven_layouts() {
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
for (group, name, version) in [
("org/apache/commons", "commons-lang3", "3.17.0"),
("com/example", "example-lib", "1.0-SNAPSHOT"),
("single", "lib", "1.0.0-rc.1"),
] {
let repository = Path::new(repository);
let dir = repository.join(group).join(name).join(version);
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
assert_eq!(found.group, group.replace('/', "."));
assert_eq!(found.name, name);
assert_eq!(found.version, version);
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
}
}
}
#[test]
fn rejects_non_artifacts_and_paths_outside_the_repository() {
let repository = Path::new("/m2/repository");
for path in [
"/m2/repository-other/g/a/1/a-1.jar",
"/m2/repository/g/a/1/a-1-sources.jar",
"/m2/repository/g/a/1/other.jar",
"/m2/repository/g/a/../a-1.jar",
"/m2/repository/g/a/1/a-1.pom",
"/m2/repository/g/a-1.jar",
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
] {
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
}
}
}
+1 -35
View File
@@ -304,7 +304,7 @@ pub fn sync_maven(
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.maven_settings.profiles(&options.profiles),
&options.profiles,
&options.maven_settings.linked_projects,
);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
@@ -818,40 +818,6 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
-66
View File
@@ -3,57 +3,6 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -261,19 +210,4 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
-3
View File
@@ -11,6 +11,3 @@ license.workspace = true
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+1 -60
View File
@@ -21,12 +21,6 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -171,12 +165,8 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(owner) } != 0 {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -240,29 +230,6 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -373,19 +340,6 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -416,19 +370,6 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+1 -1
View File
@@ -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"] }
-12
View File
@@ -20,8 +20,6 @@ pub enum McpUiRequest {
#[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,
@@ -42,10 +40,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 +146,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 {
-403
View File
@@ -1,403 +0,0 @@
//! Bounded, ordered process input. Only the worker touches the OS writer.
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
struct State {
queue: VecDeque<Vec<u8>>,
bytes: usize,
finished: bool,
aborted: bool,
error: Option<String>,
}
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
/// without joining the worker; `finish` instead drains accepted input before EOF.
pub struct InputWriter {
shared: Arc<(Mutex<State>, Condvar)>,
worker: Option<JoinHandle<()>>,
}
impl InputWriter {
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let worker_shared = shared.clone();
let worker = std::thread::Builder::new().name(name).spawn(move || {
let (lock, wake) = &*worker_shared;
let result = (|| {
loop {
let data = {
let mut state = lock.lock().unwrap();
while state.queue.is_empty() && !state.finished && !state.aborted {
state = wake.wait(state).unwrap();
}
if state.aborted {
return Ok(());
}
let Some(data) = state.queue.pop_front() else { return Ok(()) };
data
};
let mut offset = 0;
loop {
if lock.lock().unwrap().aborted {
return Ok(());
}
let flushing = offset == data.len();
let result = if !flushing {
writer.write(&data[offset..]).and_then(|n| {
if n == 0 {
Err(io::ErrorKind::WriteZero.into())
} else {
offset += n;
Ok(())
}
})
} else {
writer.flush()
};
match result {
Ok(()) if flushing => break,
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
}
Err(e) => return Err(e),
}
}
let len = data.len();
drop(data);
let mut state = lock.lock().unwrap();
state.bytes = state.bytes.saturating_sub(len);
}
})();
if let Err(e) = result {
let mut state = lock.lock().unwrap();
if state.aborted {
return;
}
state.error = Some(e.to_string());
state.queue.clear();
state.bytes = 0;
drop(state);
on_error(e);
}
})?;
Ok(Self { shared, worker: Some(worker) })
}
pub fn write(&self, data: &[u8]) -> io::Result<()> {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
if let Some(error) = &state.error {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
}
if state.finished || state.aborted {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
}
if data.is_empty() {
return Ok(());
}
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
));
}
state.bytes += data.len();
state.queue.push_back(data.to_vec());
wake.notify_one();
Ok(())
}
/// Send EOF after the accepted writes, without waiting for the child to read.
pub fn finish(&self) {
let (lock, wake) = &*self.shared;
lock.lock().unwrap().finished = true;
wake.notify_one();
}
pub fn close(&self) {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
state.aborted = true;
state.queue.clear();
wake.notify_one();
}
}
impl Drop for InputWriter {
fn drop(&mut self) {
self.close();
let Some(worker) = self.worker.take() else { return };
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
// A cancellation can race with entry into WriteFile, so retry off
// the caller's thread until the worker observes the closed queue.
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
while !worker.is_finished() {
unsafe {
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
}
std::thread::sleep(RETRY);
}
let _ = worker.join();
});
}
#[cfg(not(windows))]
drop(worker);
}
}
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
#[cfg(unix)]
use std::time::Instant;
struct SlowWriter {
ready: Arc<AtomicBool>,
output: Arc<Mutex<Vec<u8>>>,
dropped: mpsc::Sender<()>,
}
impl Write for SlowWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.ready.load(Ordering::Acquire) {
return Err(io::ErrorKind::WouldBlock.into());
}
let n = bytes.len().min(7);
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Drop for SlowWriter {
fn drop(&mut self) {
let _ = self.dropped.send(());
}
}
#[test]
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
std::thread::sleep(RETRY * 2);
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
writer.close();
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
assert!(output.lock().unwrap().is_empty());
assert!(writer.write(b"closed").is_err());
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
}
#[test]
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let paste = "abé".repeat(1024);
writer.write(paste.as_bytes()).unwrap();
writer.write(b"tail").unwrap();
writer.finish();
assert!(writer.write(b"after EOF").is_err());
ready.store(true, Ordering::Release);
rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let writer = InputWriter { shared, worker: None };
for _ in 0..MAX_WRITES {
writer.write(b"x").unwrap();
}
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
let started = Instant::now();
writer.close();
writer.worker.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_millis(250));
}
}
-3
View File
@@ -10,13 +10,11 @@ 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 +24,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;
-29
View File
@@ -1,29 +0,0 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+3 -277
View File
@@ -104,23 +104,12 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_job::Job;
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -141,48 +130,13 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -193,7 +147,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open_with(pid, access) else { continue };
let Some(handle) = open(pid) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -233,12 +187,8 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
@@ -253,167 +203,6 @@ mod windows_tree {
}
}
/// A kernel job object over one launched process tree. Everything a launch starts joins
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
/// started after the launch's snapshot was taken, orphans of a launcher that already
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
/// processes born in the window a process-enumeration kill would miss. Spawns assign
/// their root right after it starts; when the assignment fails (an enclosing job that
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
#[cfg(windows)]
mod windows_job {
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A job object holding one launch's processes. The handle is kept open for the
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
/// launch record (a run entry, a debug session) is finally dropped.
pub struct Job(OwnedHandle);
impl Job {
/// Takes the just-started `child` into a new job, so everything it starts is in
/// there too. `None` when the job could not be made or the process rejected the
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
pub fn for_child(child: &std::process::Child) -> Option<Job> {
// SAFETY: the child's own handle is a live process handle.
Self::from_handle(child.as_raw_handle())
}
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
/// a `tokio` child). `None` when the id no longer names an openable process — a
/// caller that holds the child cannot have had it reaped, so this is a dead or
/// already-gone launch.
pub fn for_pid(pid: u32) -> Option<Job> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: the handle was just opened and is owned here.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
Self::from_handle(handle.as_raw_handle())
}
fn from_handle(handle: RawHandle) -> Option<Job> {
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() || job == INVALID_HANDLE_VALUE {
return None;
}
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let job = unsafe { OwnedHandle::from_raw_handle(job) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
PerProcessUserTimeLimit: 0,
PerJobUserTimeLimit: 0,
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
MinimumWorkingSetSize: 0,
MaximumWorkingSetSize: 0,
ActiveProcessLimit: 0,
Affinity: 0,
PriorityClass: 0,
SchedulingClass: 0,
},
IoInfo: IO_COUNTERS {
ReadOperationCount: 0,
WriteOperationCount: 0,
OtherOperationCount: 0,
ReadTransferCount: 0,
WriteTransferCount: 0,
OtherTransferCount: 0,
},
ProcessMemoryLimit: 0,
JobMemoryLimit: 0,
PeakProcessMemoryUsed: 0,
PeakJobMemoryUsed: 0,
};
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let ok = unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&mut limits as *mut _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
return None;
}
// SAFETY: the process handle belongs to the just-spawned child and outlives
// this call; the job handle is owned here.
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
if ok == 0 {
return None;
}
Some(Job(job))
}
/// Adds another just-started root of the same launch to the job (a Go build's
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
/// take; the process then relies on its own kill.
pub fn assign_pid(&self, pid: u32) -> bool {
// SAFETY: plain call; the handle is opened only to assign it, and the job
// handle is owned and valid.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return false;
}
// SAFETY: both handles are valid for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
// SAFETY: the process handle was opened above and is owned here.
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
ok != 0
}
/// Terminates every process in the job. `false` when the call failed (the job
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
/// the tree is gone.
pub fn terminate(&self) -> bool {
// SAFETY: a job handle owned here.
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
}
/// Whether any process of the launch is still in the job. When the job's state
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
/// [`Job::terminate`] does not depend on the answer.
pub fn is_running(&self) -> bool {
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
// longer, which means there are processes.
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
NumberOfAssignedProcesses: 0,
NumberOfProcessIdsInList: 0,
ProcessIdList: [0],
};
let mut returned = 0u32;
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
let ok = unsafe {
QueryInformationJobObject(
self.0.as_raw_handle(),
JobObjectBasicProcessIdList,
&mut list as *mut _ as *mut _,
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
&mut returned,
)
};
if ok != 0 {
list.NumberOfProcessIdsInList > 0
} else {
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
}
}
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -1119,66 +908,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());
}
}
-24
View File
@@ -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::*;
+5 -297
View File
@@ -59,16 +59,16 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Settings › Agents.
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// 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 {
@@ -80,125 +80,15 @@ 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>>,
}
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(),
}
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// A hand-edited value these settings don't know reads as the default, rather than
/// failing the whole settings file.
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
Ok(agents
.into_iter()
.filter_map(|(agent, options)| match options {
serde_json::Value::Object(options) => Some((
agent,
options
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(v) => Some((k, v)),
_ => None,
})
.collect(),
)),
_ => None,
})
.collect())
}
/// How the agent's reasoning shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThinkingDisplay {
/// Open while it streams, folded once it ends.
#[default]
Auto,
Expanded,
Collapsed,
}
/// How tool calls between messages show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ToolCallDisplay {
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
#[default]
Grouped,
/// Each call on its own line.
Each,
}
/// The key that sends a message; the other one starts a new line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SendKey {
#[default]
Enter,
ModEnter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WhileRunning {
#[default]
Queue,
Steer,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
/// Tools › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
@@ -376,10 +266,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.
@@ -395,7 +281,6 @@ pub struct GeneralSettings {
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
}
impl Default for GeneralSettings {
@@ -403,8 +288,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,
@@ -413,7 +296,6 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
}
}
}
@@ -468,21 +350,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 {
@@ -519,8 +386,6 @@ pub struct AppearanceSettings {
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
pub main_menu_alt: bool,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -528,12 +393,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>,
@@ -597,16 +456,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,
}
@@ -644,9 +499,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,
@@ -691,7 +543,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(),
@@ -736,15 +587,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.
@@ -772,7 +614,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -780,8 +621,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,
@@ -794,13 +633,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(),
@@ -816,22 +653,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")]
@@ -934,8 +759,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,
@@ -963,8 +786,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,
}
@@ -1049,8 +870,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.
@@ -1107,8 +926,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
@@ -1120,25 +937,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.
@@ -1334,26 +1135,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();
@@ -1416,21 +1197,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();
@@ -1460,33 +1226,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]
@@ -1653,24 +1399,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.
@@ -1683,26 +1411,6 @@ mod maven_linkage_tests {
}
}
#[cfg(test)]
mod java_settings_tests {
use super::*;
#[test]
fn shared_jdtls_defaults_off_and_round_trips() {
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
let java: JavaSettings = serde_json::from_str(old).unwrap();
assert!(!java.jdtls_shared);
}
for shared in [false, true] {
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
let json = serde_json::to_value(&java).unwrap();
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
-151
View File
@@ -1,151 +0,0 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashSet;
use std::time::{Duration, Instant};
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
#[default]
Github,
Cnb,
}
impl UpdateSource {
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
match preference {
"github" => return Self::Github,
"cnb" => return Self::Cnb,
_ => {}
}
let locale = locale.trim();
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
Self::Cnb
} else {
Self::Github
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// Unix milliseconds of the last successful check in this process.
pub last_checked: Option<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
impl UpdatePolicy {
pub fn due_in(&self, now: Instant) -> Duration {
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
}
pub fn begin(&mut self, now: Instant) {
self.attempted = Some(now);
self.status.checking = true;
}
pub fn finish(&mut self, checked_at: Option<f64>) {
self.status.checking = false;
if let Some(at) = checked_at {
self.status.last_checked = Some(at);
}
}
pub fn announced(&self, version: &str) -> bool {
self.announced.contains(version)
}
/// Manual checks always show their result, but also count as an announcement.
pub fn announce(&mut self, version: &str) -> bool {
self.announced.insert(version.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
for (preference, locale, system, expected) in [
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
("auto", "system", "zh-CN", UpdateSource::Cnb),
("auto", "auto", "zh-TW", UpdateSource::Cnb),
("auto", "", "zh", UpdateSource::Cnb),
("auto", "en", "zh-CN", UpdateSource::Github),
("github", "zh", "zh-CN", UpdateSource::Github),
("cnb", "en", "en-US", UpdateSource::Cnb),
] {
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
}
}
#[test]
fn checks_at_startup_and_every_four_hours_even_after_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
assert_eq!(policy.due_in(now), Duration::ZERO);
policy.begin(now);
assert!(policy.status.checking);
policy.finish(None);
assert_eq!(policy.status.last_checked, None);
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
policy.begin(now + CHECK_INTERVAL);
policy.finish(Some(1234.0));
assert_eq!(policy.status.last_checked, Some(1234.0));
assert!(!policy.status.checking);
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
}
#[test]
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
policy.begin(now);
policy.finish(Some(1000.0));
policy.begin(now + Duration::from_secs(60));
policy.finish(None);
assert_eq!(policy.status.last_checked, Some(1000.0));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
}
#[test]
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
let mut policy = UpdatePolicy::default();
assert!(policy.announce("0.1.40"));
assert!(!policy.announce("0.1.40"));
assert!(policy.announce("0.1.41"));
assert!(!policy.announce("0.1.40"));
assert!(UpdatePolicy::default().announce("0.1.40"));
}
}
+1 -9
View File
@@ -272,8 +272,6 @@ impl Default for PythonDebuggerSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunSettings {
/// Target for configurations without an explicit target. Empty = local.
pub default_target: String,
pub confirm_rerun: bool,
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
@@ -284,13 +282,7 @@ pub struct RunSettings {
impl Default for RunSettings {
fn default() -> Self {
Self {
default_target: String::new(),
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
termination_timeout_ms: 3000,
}
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
}
}
+1 -1
View File
@@ -25,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
View File
@@ -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")));
}
}
+25 -150
View File
@@ -282,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.
@@ -304,63 +305,6 @@ pub struct DebugManager {
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
/// arguments and environment: they go on its command line after `--` and into its process.
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
if config.request != "launch" {
return Ok(());
}
if spec.command == "rdbg"
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
{
if !bundles_debug(&gemfile) {
return Err(IdeError::unavailable(format!(
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
gemfile.display()
)));
}
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
spec.command = "bundle".into();
}
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
spec.args.push("--".into());
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
// without arguments go through -S so spaces and metacharacters stay literal.
if config.program.is_some() && config.args.is_empty() {
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
}
spec.args.extend(config.program.iter().cloned());
spec.args.extend(config.args.iter().cloned());
Ok(())
}
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
let gemfile = env
.get("BUNDLE_GEMFILE")
.cloned()
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
match gemfile.filter(|g| !g.is_empty()) {
Some(gemfile) => Some(dir.join(gemfile)),
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
}
}
/// Whether the lockfile of `gemfile` lists the `debug` gem.
fn bundles_debug(gemfile: &Path) -> bool {
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
gemfile.with_file_name("gems.locked")
} else {
let mut name = gemfile.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
};
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
@@ -607,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());
}
}
}
@@ -718,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;
}
@@ -754,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")
@@ -875,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());
@@ -910,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() {
@@ -940,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")
@@ -1003,32 +902,19 @@ impl DebugManager {
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
let mut launch = if launch_in_config {
None
} else {
let reply = s.conn.send_request(request, arguments.clone())?;
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
};
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = match launch.as_mut() {
Some(launch) => tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = launch => {
r?;
launch_result = Some(());
false
}
},
None => {
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
true
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
}
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
};
if !got_initialized {
// Launched before `initialized`; give the adapter a moment to send it.
@@ -1085,16 +971,12 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
if launch_result.is_none() {
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
Ok(r) => {
r?;
}
}
None => {
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
let reply = s.conn.send_request(request, arguments)?;
reply.await.unwrap_or(Err(DapError::Closed))?;
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
@@ -1115,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;
@@ -1145,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
@@ -1782,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;
+28 -255
View File
@@ -45,18 +45,9 @@ pub(crate) struct Session {
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// The job object over the adapter and everything it started: the program an adapter
/// like java-debug launches is its child, so a forced stop (and the session's
/// cleanup) ends the whole tree even when the adapter exited first and the reported
/// pid no longer names it. `None` when the assignment failed; the kill then falls
/// back to the captured process tree.
#[cfg(windows)]
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
@@ -66,8 +57,6 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -98,10 +87,6 @@ impl Session {
port: Option<u16>,
child: Option<Child>,
) -> Self {
// Before `child` moves into the session: the adapter is the root of the job, so
// everything it starts (java-debug's JVM) is in there by inheritance.
#[cfg(windows)]
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
Self {
id,
owner,
@@ -116,18 +101,12 @@ impl Session {
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -295,30 +274,7 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
#[cfg(unix)]
if self.request == "launch" {
for group in launch_groups(self) {
ide_core::process::kill_tree_of(group as u32);
}
}
// The job first: it ends the adapter and everything it started (the program
// java-debug launched is its child), members included that the stop's captured
// tree never saw. An empty job is unaffected, and whatever survives this session
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
#[cfg(windows)]
if let Some(job) = self.job.lock().as_ref() {
job.terminate();
}
#[cfg(windows)]
if let Some(tree) = self.stop_tree.lock().as_ref() {
tree.terminate();
}
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = c.start_kill();
}
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
@@ -326,9 +282,6 @@ impl Session {
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -336,21 +289,6 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -380,36 +318,18 @@ fn process_tree(pid: i32) -> Vec<i32> {
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
if s.request != "launch" {
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(not(windows))]
let pid = s.debuggee_pid.load(Ordering::Acquire);
#[cfg(unix)]
{
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
if needs_tree {
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
*s.stop_pids.lock() = tree;
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let known = s.stop_pids.lock();
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
if force {
// A forced stop kills the launch: every root the session spawned leads its own
// process group (`own_group` at spawn), so one signal reaches the program and
// everything it started, even processes born after the snapshot below was
// taken — the adapter's program is in the adapter's group. The session's own
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
// The roots are unreaped until the session drops them, so their ids (and the
// groups named by them) cannot have been reused yet.
for group in launch_groups(s) {
// SAFETY: plain syscalls on the processes this session started.
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
unsafe { libc::kill(group, libc::SIGKILL) };
}
}
}
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
@@ -420,53 +340,17 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
// Console programs have no external graceful signal; the adapter gets its chance
// first. The job ends the program and everything it started, members included
// that the captured tree never saw.
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
if let Some(job) = s.job.lock().as_ref() {
job.terminate();
}
// A separately spawned program may have rejected assignment to the adapter's job.
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// The process groups the session spawned roots for: the adapter's (the program it
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
#[cfg(unix)]
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
let mut groups = Vec::new();
let mut push = |id: Option<u32>| {
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
groups.push(pid);
}
};
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
/// Whether the launch is still running: the stop's captured tree when there is one,
/// otherwise the session's job (the adapter and everything it started).
#[cfg(windows)]
fn launch_running(s: &Arc<Session>) -> bool {
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.is_running()
} else {
s.job.lock().as_ref().is_some_and(|job| job.is_running())
}
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
@@ -480,39 +364,25 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
#[cfg(unix)]
{
use std::time::Instant;
let mut targets = s.stop_pids.lock().clone();
if targets.is_empty() {
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
}
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true).await;
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true).await;
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
// The captured tree is the oracle when it was captured; otherwise the job (every
// process the launch started) is, so a failed capture can no longer end the wait
// while the program still holds its port.
while launch_running(s) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
#[cfg(not(unix))]
{
let _ = pid;
}
@@ -578,11 +448,6 @@ pub(crate) async fn event_loop(
Incoming::Closed => break,
}
}
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
if session.adapter == "rdbg" && session.request == "launch" {
let mut drained = session.output_drained.subscribe();
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
}
if let Some(mgr) = manager.upgrade() {
mgr.session_closed(&session);
}
@@ -723,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()
}
}
@@ -750,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"));
@@ -785,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();
}
@@ -826,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 || {
@@ -838,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) {
@@ -920,6 +726,7 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -932,9 +739,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -961,33 +766,20 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
}
}
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
// forced stop), the session's job on Windows, so a stop reaches what it forks.
ide_core::process::own_group(cmd.as_std_mut());
let mut child = cmd.spawn()?;
#[cfg(windows)]
if let Some(pid) = child.id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -1001,22 +793,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
+18 -541
View File
@@ -14,93 +14,6 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -120,16 +33,6 @@ struct Script {
supports_terminate: bool,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
initialized_delay: Duration,
launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
close_during_startup: bool,
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
}
impl Default for Script {
@@ -144,13 +47,6 @@ impl Default for Script {
output_after_terminate: false,
supports_terminate: true,
hold_set_breakpoints: 0,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
}
}
}
@@ -165,12 +61,10 @@ impl AdapterLauncher for FakeLauncher {
let (client, server) = tokio::io::duplex(1 << 16);
let log = self.log.clone();
let script = self.script.clone();
let launch_after_configuration = script.launch_after_configuration;
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo {
launch_after_configuration,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -188,7 +82,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut running = false;
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -209,46 +102,24 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => {
ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
);
if script.gdb15_starts_at_launch {
// GDB 15 announces itself before it sees `launch` (which a client that
// defers it sends late).
send!(json!({"type": "event", "event": "initialized"}));
}
}
"initialize" => ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
),
"launch" | "attach" => {
if script.gdb15_starts_at_launch {
// GDB 15 starts the program here and answers `launch` right away.
running = true;
ok!(msg, Value::Null);
continue;
}
if script.close_during_startup {
return;
}
if let Some(message) = script.startup_error {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
);
continue;
}
pending_launch = msg["seq"].as_i64();
if let Some(args) = &script.run_in_terminal {
send!(
@@ -260,10 +131,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
);
}
if script.pending_startup != Some(false) {
tokio::time::sleep(script.initialized_delay).await;
send!(json!({"type": "event", "event": "initialized"}));
}
send!(json!({"type": "event", "event": "initialized"}));
}
"setBreakpoints" => {
held.push(msg);
@@ -311,21 +179,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if script.gdb15_starts_at_launch && running {
// GDB 15 answers `configurationDone` with `notStopped` while the program
// it started at `launch` is still running.
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
);
continue;
}
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
if script.pending_startup == Some(true) {
continue;
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -495,119 +351,6 @@ fn bp(line: u32) -> Breakpoint {
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
}
#[tokio::test(start_paused = true)]
async fn slow_launch_and_attach_follow_adapter_startup() {
for request in ["launch", "attach"] {
for before_initialized in [true, false] {
let delay = Duration::from_secs(125);
let h = harness(Script {
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
launch_delay: if before_initialized { Duration::ZERO } else { delay },
..Script::default()
});
let config = LaunchConfig { request: request.into(), ..config() };
let id = h.start(config).await.expect("a slow adapter can finish starting");
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test(start_paused = true)]
async fn stop_cancels_pending_launch_and_attach() {
for request in ["launch", "attach"] {
for configured in [false, true] {
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
let config = LaunchConfig { request: request.into(), ..config() };
let start = h.start(config);
tokio::pin!(start);
let stop = async {
loop {
let command = if configured { "configurationDone" } else { request };
if h.commands().iter().any(|c| c == command) {
break;
}
tokio::task::yield_now().await;
}
let id = h.manager.sessions(None)[0].id;
h.manager.stop(id).await.unwrap();
};
let (result, ()) = tokio::join!(&mut start, stop);
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
assert!(h.manager.sessions(None).is_empty());
}
}
}
/// GDB 15 must receive breakpoints and configurationDone before launch.
#[tokio::test(start_paused = true)]
async fn gdb_15_launch_waits_for_the_configuration() {
for deferred in [false, true] {
let h =
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
if deferred {
let id = result.expect("GDB 15 defers its launch past the configuration");
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
} else {
let error = result.unwrap_err();
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
}
}
}
#[tokio::test(start_paused = true)]
async fn gdb_attach_keeps_the_normal_configuration_order() {
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn gdb_run_environment_overrides_preserve_inheritance() {
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
let h = harness(Script::default());
let id = h
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
.await
.unwrap();
let session = h.manager.session(id).unwrap();
{
let log = h.log.lock();
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
if explicit.is_some() {
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
assert!(session.spec.env.is_empty());
} else {
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
assert!(session.spec.env.contains(&("A".into(), "1".into())));
}
}
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test(start_paused = true)]
async fn startup_still_reports_adapter_failure_and_connection_closure() {
for request in ["launch", "attach"] {
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
assert!(h.manager.sessions(None).is_empty());
let h = harness(Script { close_during_startup: true, ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
assert!(h.manager.sessions(None).is_empty());
}
}
#[tokio::test]
async fn full_protocol_flow() {
let h = harness(Script::default());
@@ -811,22 +554,6 @@ async fn run_in_terminal_program_reads_typed_input() {
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn blocked_program_input_does_not_hold_up_shutdown() {
let h = harness(Script {
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
let started = std::time::Instant::now();
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
assert!(started.elapsed() < std::time::Duration::from_millis(250));
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "later").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
@@ -1123,39 +850,6 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
@@ -1697,109 +1391,6 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("ruby".into()),
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(
spec.args,
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
);
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
}
#[test]
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
// A dependency of another gem is not a gem of the bundle.
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("bundle".into()),
args: ["exec", "rspec"].map(String::from).to_vec(),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, app.clone());
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
// From a subdirectory too: Bundler finds the Gemfile above it.
for cwd in [app.clone(), app.join("lib")] {
let mut spec = adapter::resolve(&rdbg, cwd.clone());
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
assert_eq!(spec.command, "bundle");
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
}
}
#[test]
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
let config =
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
}
}
#[test]
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
let args = spec.args.clone();
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(spec.args, args);
}
#[test]
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
let dir = tempfile::tempdir().unwrap();
for name in ["Gemfile", "gems.rb"] {
std::fs::write(dir.path().join(name), "").unwrap();
}
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
}
#[test]
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("gems")).unwrap();
let none = BTreeMap::new();
assert_eq!(bundler_gemfile(root, &none), None);
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
assert_eq!(gems_rb, root.join("gems/gems.rb"));
assert!(bundles_debug(&gems_rb));
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
@@ -2017,117 +1608,3 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
struct Launcher;
impl AdapterLauncher for Launcher {
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
Box::pin(async {
let (client, server) = tokio::io::duplex(1 << 16);
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
let mut command = tokio::process::Command::new("/bin/sh");
ide_core::process::own_group(command.as_std_mut());
let mut child = command
.args(["-c", "sleep 1; printf 'test summary\\n'"])
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut text = String::new();
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
let _ = tx.send(("stdout", text));
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
port: None,
stderr: Arc::default(),
program_output: Some(rx),
})
})
}
}
let dir = tempfile::tempdir().unwrap();
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
let events = Arc::new(Mutex::new(Vec::new()));
let collected = events.clone();
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
let id = manager
.start(
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
owner,
)
.await
.unwrap();
manager.session(id).unwrap().conn.close();
tokio::time::timeout(Duration::from_secs(5), async {
while !manager.sessions(None).is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(events.lock().iter().any(|e| matches!(e,
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
)));
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
use tokio::io::AsyncBufReadExt;
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
let mut command = tokio::process::Command::new("python3");
ide_core::process::own_group(command.as_std_mut());
command
.args([
"-u",
"-c",
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
])
.stdout(std::process::Stdio::piped());
let mut child = command.spawn().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
let pid = child.id().unwrap();
*session.adapter_process.lock() = Some(child);
session.kill_processes();
let deadline = Instant::now() + Duration::from_secs(3);
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
if Instant::now() >= deadline {
ide_core::process::kill_tree_of(pid);
panic!("adapter cleanup left its descendant holding the port");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_detaches_from_an_attached_program() {
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
h.manager.stop(id).await.unwrap();
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "Stop killed the attached program");
assert!(!h.commands().contains(&"terminate".to_owned()));
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
}
-20
View File
@@ -151,16 +151,6 @@ fn cases(dir: &Path) -> Vec<Case> {
field: None,
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
},
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
Case {
adapter: "gdb",
source: c.join("main.c"),
loop_line: 13,
after_loop: 15,
function: "parse",
field: None,
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
},
]);
out
}
@@ -240,16 +230,6 @@ fn installed(adapter: &str) -> bool {
"js-debug" => "js-debug-adapter",
"delve" => "dlv",
"java-debug" => return true,
// Only a GDB with the DAP interpreter (14+) counts as installed.
"gdb" => {
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(adapter::parse_gdb_version)
.is_some_and(|(major, _)| major >= 14)
});
}
other => other,
};
adapter::resolve_binary(cmd, Path::new("/")).is_some()
-3
View File
@@ -7,13 +7,10 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
-18
View File
@@ -594,13 +594,6 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -846,8 +839,6 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -1474,15 +1465,6 @@ mod tests {
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+13 -25
View File
@@ -7,7 +7,6 @@ pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod plugin;
pub mod secrets;
pub mod source;
pub mod split;
@@ -92,7 +91,7 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// A schema's routines (procedures and functions) and sequences.
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
@@ -382,9 +381,7 @@ impl DbManager {
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
store
}
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
@@ -415,28 +412,19 @@ impl DbManager {
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
}
let old = {
let _guard = plugin::EDIT.lock();
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
None if !global.is_empty() => {
return Err(IdeError::invalid("global data sources are not available here"));
}
None => {}
}
source::save(&self.root, &project)?;
old
};
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
-271
View File
@@ -1,271 +0,0 @@
//! Ownership checks and surgical updates of plugin-created project sources.
use crate::{DataSource, DbManager, source};
use ide_core::{IdeError, IdeResult, project_dir};
use std::path::Path;
// All DbManagers of the same root share this file. Never hold this across an await.
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// Host-owned connection id, unambiguous and safe for console paths.
pub fn source_id(plugin: &str, id: &str) -> String {
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
}
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
source.id == source_id(plugin, id)
&& source.plugin_owner.as_deref() == Some(plugin)
&& source.plugin_source_id.as_deref() == Some(id)
&& !source.global
}
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, "data-sources.toml");
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
&& root.exists()
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
{
return Err(IdeError::invalid("data sources file is outside the project"));
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let old = source::load(root)?;
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
while end < text.len() {
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
if !text[end..next].trim().is_empty() {
break;
}
end = next;
}
out.push(start..end);
}
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
if !table.is_implicit()
&& let Some(span) = table.span()
{
line_span(text, span, out);
}
for (_, item) in table.iter() {
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
line_span(text, span, out);
}
if let Some(table) = item.as_table() {
spans(table, text, out);
}
if let Some(tables) = item.as_array_of_tables() {
for table in tables.iter() {
spans(table, text, out);
}
}
}
}
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
}
let mut ranges = vec![];
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
for (table, source) in tables.iter().zip(&old) {
if remove(source) {
spans(table, &text, &mut ranges);
}
}
}
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<std::ops::Range<usize>> = vec![];
for range in ranges {
if let Some(last) = merged.last_mut()
&& range.start <= last.end
{
last.end = last.end.max(range.end);
continue;
}
merged.push(range);
}
let mut output = text.clone();
for range in merged.into_iter().rev() {
output.replace_range(range, "");
}
if let Some(add) = add {
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
value.as_table_mut().unwrap().remove("global");
let value =
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
}
if !removed.is_empty() || add.is_some() {
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&tmp, output)?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
}
Ok(removed)
}
/// Removes only this plugin's sources, including when their project is closed at uninstall.
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
let _guard = EDIT.lock();
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
}
impl DbManager {
/// Lists private source ids without exposing any connection settings.
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
Ok(source::load(&self.root)?
.into_iter()
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
.filter_map(|s| s.plugin_source_id)
.collect())
}
/// Checks the stored owner, even if a user-created source uses the host's id format.
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
source::load(&self.root)?
.into_iter()
.find(|s| owned(s, plugin, id))
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
}
/// Replaces only an owned source and disconnects its old pools.
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
source.id = source_id(plugin, id);
source.plugin_owner = Some(plugin.into());
source.plugin_source_id = Some(id.into());
source.global = false;
{
let _guard = EDIT.lock();
let all = self.sources()?;
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
return Err(IdeError::not_found("no owned data source with this id"));
}
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
}
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
}
self.disconnect_source(&source.id).await;
Ok(())
}
/// Deletes an owned source and all its saved/session credentials.
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
let source = {
let _guard = EDIT.lock();
let source = self.plugin_source(plugin, id)?;
update(&self.root, |s| owned(s, plugin, id), None)?;
source
};
self.forget_plugin_source(&source.id).await;
Ok(())
}
/// Completes cleanup after removal from the file.
pub async fn forget_plugin_source(&self, id: &str) {
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
self.session_passwords.lock().remove(&key);
self.saved_passwords.delete(&key);
}
self.disconnect_source(id).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DbDriver, secrets::SecretStore};
#[tokio::test]
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
let file = root.join(".openidea/data-sources.toml");
std::fs::write(&file, original).unwrap();
let secrets = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
secrets.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let settings =
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
assert!(db.plugin_source("a", "user").is_err());
assert!(db.remove_plugin_source("a", "user").await.is_err());
let source = db.plugin_source("a", "local").unwrap();
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
let store = SecretStore::new(secrets, &root);
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.remove_plugin_source("a", "local").await.unwrap();
assert!(store.get(&source.id).is_none());
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
remove_all(&root, "b").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
#[tokio::test]
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let global = tmp.path().join("global");
let passwords = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
passwords.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
)
.with_global_sources(global.clone());
db.upsert_plugin_source(
"plugin",
"local",
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
)
.await
.unwrap();
let owned = db.plugin_source("plugin", "local").unwrap();
source::save_global(
&global,
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
)
.unwrap();
let global_store = SecretStore::new(passwords.clone(), &global);
global_store.set(&owned.id, "user-secret").unwrap();
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
db.remove_plugin_source("plugin", "local").await.unwrap();
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
}
#[tokio::test]
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
let tmp = tempfile::tempdir().unwrap();
let db = DbManager::new(
tmp.path().join("project"),
tmp.path().join("passwords.json"),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
db.save_sources(vec![source.clone()]).await.unwrap();
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
assert_eq!(db.sources().unwrap(), [source]);
}
}
+1 -10
View File
@@ -190,12 +190,6 @@ pub struct DataSource {
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
/// Plugin id owning this source; absent for user-managed sources.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_owner: Option<String>,
/// Id private to the owner, never used as a connection target.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_source_id: Option<String>,
}
impl Default for DataSource {
@@ -225,8 +219,6 @@ impl Default for DataSource {
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
plugin_owner: None,
plugin_source_id: None,
}
}
}
@@ -270,7 +262,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -559,7 +551,6 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
-26
View File
@@ -253,32 +253,6 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+19 -181
View File
@@ -41,15 +41,6 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -89,10 +80,6 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -103,46 +90,6 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -239,20 +186,17 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -268,13 +212,10 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -317,23 +258,17 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -348,121 +283,24 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+1 -44
View File
@@ -1,9 +1,6 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -25,16 +22,11 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -52,44 +44,9 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+5 -216
View File
@@ -20,10 +20,6 @@ pub enum Capability {
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
/// Run arbitrary IDE actions and other plugins' commands.
IdeCommands,
/// Manage only its own project database sources and write their credentials.
DatabaseSources,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
@@ -36,16 +32,8 @@ pub enum Capability {
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Ask the user when the first request goes to an HTTPS (or loopback) host
/// the manifest does not list; each answer applies to one host. Plain
/// HTTP elsewhere always needs `net.http:<host>`.
NetAsk,
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
@@ -72,15 +60,12 @@ impl Capability {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetAsk
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
}
}
@@ -97,8 +82,6 @@ impl Capability {
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::ProjectWrite => "Create and change files in the open project".into(),
Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
@@ -109,16 +92,6 @@ impl Capability {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetAsk => {
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
.into()
}
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
Self::Ipc(path) => {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
@@ -127,14 +100,8 @@ impl Capability {
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
if self == wanted {
return true;
}
match (self, wanted) {
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
host_matches(pattern, host)
}
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
@@ -153,75 +120,6 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
}
}
/// Validates a socket path; a grant may use `*` in its last segment only.
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
let pipe = name.starts_with(r"\\.\pipe\");
let unix = name.starts_with('/')
|| name.starts_with("~/")
|| name.starts_with("$TMPDIR/")
|| name.starts_with("$XDG_RUNTIME_DIR/");
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
if !(pipe || unix)
|| name.contains(['\0', '?'])
|| name.len() > 4096
|| parts.last().is_none_or(|p| p.is_empty())
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|| (!pattern && name.contains('*'))
{
return Err(
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
);
}
Ok(())
}
/// Expands the three supported host-local prefixes; no shell is involved.
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
validate_ipc_name(name, true)?;
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
if let Some(rest) = name.strip_prefix(prefix) {
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
validate_ipc_name(&expanded, true)?;
return Ok(expanded);
}
}
Ok(name.into())
}
fn ipc_matches(pattern: &str, name: &str) -> bool {
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
if validate_ipc_name(&name, false).is_err() {
return false;
}
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
else {
return false;
};
if parent != other {
return false;
}
let mut rest = name;
let mut pieces = pattern.split('*');
let first = pieces.next().unwrap_or_default();
let Some(tail) = rest.strip_prefix(first) else { return false };
rest = tail;
let pieces: Vec<_> = pieces.collect();
if pieces.is_empty() {
return rest.is_empty();
}
for (i, piece) in pieces.iter().enumerate() {
if i + 1 == pieces.len() {
return rest.ends_with(piece);
}
let Some(at) = rest.find(piece) else { return false };
rest = &rest[at + piece.len()..];
}
true
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -229,8 +127,6 @@ impl fmt::Display for Capability {
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::IdeCommands => f.write_str("ide.commands"),
Self::DatabaseSources => f.write_str("database.sources"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -239,9 +135,6 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetAsk => f.write_str("net:ask"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
@@ -260,10 +153,7 @@ impl FromStr for Capability {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
"net" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -272,19 +162,7 @@ impl FromStr for Capability {
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid {
Ok(if kind == "net" {
Self::Net(arg.to_ascii_lowercase())
} else {
Self::NetHttp(arg.to_ascii_lowercase())
})
} else {
Err(format!("invalid host in {s:?}"))
}
}
"ipc" => {
validate_ipc_name(arg, true)?;
Ok(Self::Ipc(arg.to_owned()))
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
@@ -306,8 +184,6 @@ impl FromStr for Capability {
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"ide.commands" => Self::IdeCommands,
"database.sources" => Self::DatabaseSources,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -416,90 +292,3 @@ mod tests {
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
let http: Capability = "net.http:*.example.com".parse().unwrap();
assert_eq!(http.to_string(), "net.http:*.example.com");
assert_eq!(http.risk(), Risk::Medium);
assert!(http.describe().contains("unencrypted"));
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
assert_eq!(socket.risk(), Risk::High);
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
assert!(
r"ipc:\\.\pipe\discord-*"
.parse::<Capability>()
.unwrap()
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
);
for invalid in
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
{
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
}
}
}
#[cfg(test)]
mod command_tests {
use super::*;
#[test]
fn ide_commands_is_explicit_and_high_risk() {
let cap: Capability = "ide.commands".parse().unwrap();
assert_eq!(cap, Capability::IdeCommands);
assert_eq!(cap.to_string(), "ide.commands");
assert_eq!(cap.risk(), Risk::High);
assert!(!cap.auto_migrates());
assert!(!Capability::VcsWrite.covers(&cap));
assert!(!Capability::Process("git".into()).covers(&cap));
}
}
#[cfg(test)]
mod net_ask_tests {
use super::*;
#[test]
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
let cap: Capability = "net:ask".parse().unwrap();
assert_eq!(cap, Capability::NetAsk);
assert_eq!(cap.to_string(), "net:ask");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
// It asks; it does not itself connect anywhere or pre-approve a host.
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
#[test]
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
let cap: Capability = "database.sources".parse().unwrap();
assert_eq!(cap, Capability::DatabaseSources);
assert_eq!(cap.to_string(), "database.sources");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
assert!(!Capability::ProjectRead.covers(&cap));
assert!(!Capability::ProjectWrite.covers(&cap));
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
let text = format!(
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
);
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
}
}
}
-133
View File
@@ -53,21 +53,12 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Project root → process grant → the executable approved for that project.
#[serde(default)]
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
/// Explicit global executable choices, retained for the next re-approval.
#[serde(default)]
pub program_choices: BTreeMap<String, PathBuf>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
@@ -204,9 +195,6 @@ impl Ledger {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
@@ -231,9 +219,6 @@ impl Ledger {
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
@@ -261,35 +246,13 @@ impl Ledger {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Net(host) = cap {
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
}
if cap == &Capability::NetAsk {
grant.net_hosts.clear();
}
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
binaries.remove(&s);
}
}
grant.granted.remove(&s)
}
}
/// Remembers one exact host without granting a manifest capability.
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
return false;
}
grant.net_hosts.insert(host.to_owned())
}
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
@@ -297,58 +260,6 @@ impl Ledger {
}
}
/// The user's explicit global executable choice, if any.
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
}
/// Remembers or resets an explicit global executable choice after confirmation.
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(path) = path {
grant.program_choices.insert(cap.to_string(), path.into());
} else {
grant.program_choices.remove(&cap.to_string());
}
}
}
/// Updates a project-specific program choice after user confirmation.
pub fn set_project_binary(
&mut self,
subject: &Subject,
cap: &Capability,
root: &Path,
binary: Option<BinaryGrant>,
) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(binary) = binary {
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
binaries.remove(&cap.to_string());
}
}
}
/// The project override when present, otherwise the global approved program.
pub fn check_binary_in_project(
&self,
subject: &Subject,
cap: &Capability,
root: Option<&Path>,
) -> Result<PathBuf, String> {
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
if !grant.has(cap) {
return Err(format!("{cap} was not granted"));
}
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
{
return match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
};
}
self.check_binary(subject, cap)
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
@@ -395,16 +306,6 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
if !path.is_absolute() || !is_executable(path) {
return Err("choose an absolute executable file".into());
}
let path = canonical_dir(path);
let hash = file_hash(&path).map_err(|e| e.to_string())?;
Ok(BinaryGrant { path, hash })
}
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
/// The directory is canonicalized but the file name kept as found: `cargo`,
@@ -538,40 +439,6 @@ mod tests {
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
let r = req("1.0.0", "h", &["net:ask"], &[]);
l.confirm(&r, &[], BTreeMap::new());
let host = "api.example.com";
assert!(l.remember_net_host(&r.subject, host));
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
l.save().unwrap();
l = Ledger::load(tmp.path());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
l.confirm(&r, &[], BTreeMap::new());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
for confirm in [false, true] {
l.remember_net_host(&r.subject, host);
// A newly declared host must not inherit an ask grant after net:ask is removed.
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
if confirm {
l.confirm(&update, &[], BTreeMap::new());
} else {
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
}
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
l.remember_net_host(&r.subject, host);
l.set(&r.subject, &Capability::NetAsk, false);
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -24,7 +24,7 @@ mod registry;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
+3 -90
View File
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.5.0";
pub const ENGINE_VERSION: &str = "1.3.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -144,20 +144,8 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &[
"run-exited",
"terminal-exited",
"content-tab-action",
"file-closed",
"settings-changed",
"input-mode-changed",
"file-opened",
"file-saved",
"files-changed",
"editor-focused",
"action-performed",
"vcs-changed",
];
pub const EVENT_KINDS: &[&str] =
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -329,18 +317,9 @@ impl Manifest {
check_relative(icon, "icon")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(
cap,
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
) {
self.needs_api(&format!("capability `{cap}`"), 4)?;
}
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -353,10 +332,6 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
@@ -680,65 +655,3 @@ mod tests {
assert!(!engine_compatible("0.10", true));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn new_capabilities_and_activations_require_api_1_4() {
for declaration in [
"[capabilities]\nrequired = [\"net.http:example.com\"]",
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
"activation = [\"on-event:file-closed\"]",
"activation = [\"on-event:settings-changed\"]",
"activation = [\"on-event:input-mode-changed\"]",
"activation = [\"on-event:run-exited\"]",
"activation = [\"on-event:terminal-exited\"]",
"activation = [\"on-event:content-tab-action\"]",
] {
for engine in ["^1.0", "^1.3", "^1.4"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.4" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
}
}
}
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
#[test]
fn ide_commands_requires_api_1_4() {
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
}
}
#[cfg(test)]
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.5" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
}
}
}
}
}
+2 -2
View File
@@ -627,8 +627,8 @@ mod tests {
#[test]
fn finds_remote_by_repo() {
let remotes = vec![
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
];
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
-1
View File
@@ -52,7 +52,6 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
jsonschema = { version = "0.33", default-features = false }
serde_json = { workspace = true, features = ["raw_value"] }
toml.workspace = true
specta.workspace = true
@@ -368,9 +368,7 @@ fn check(dir: &Path) -> Result<(), String> {
let manifest = Manifest::load(&dir)?;
println!("{} {} — {}", preview.id, preview.version, preview.name);
if manifest.legacy {
println!(
" note: plugin.toml uses the 0.1 format (`openidea-api`); its code no longer runs: rebuild it against the 1.x SDK and declare `engines.openidea` (see plugins/README.md)"
);
println!(" note: plugin.toml uses the 0.1 format (`openidea-api`); see plugins/README.md to move to 0.2");
}
if let Some(wasm) = &manifest.wasm {
let engine = PluginEngine::new()?;
@@ -208,7 +208,7 @@ authors = []
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: plugins/themes.md.
# the theme's (IntelliJ's .theme.json + .icls pair). Reference: docs/plugins/themes.md.
[[contributes.themes]]
path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
-124
View File
@@ -1,37 +1,9 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -304,102 +276,6 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_preserves_an_existing_probe_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let probe = dir.join(".openidea-write-check");
fs::write(&probe, "keep").unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let target = tmp.path().join("target");
fs::write(&target, "keep").unwrap();
let probe = dir.join(".openidea-write-check");
std::os::unix::fs::symlink(&target, &probe).unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[test]
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
let tmp = tempfile::tempdir().unwrap();
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
let barrier = &barrier;
let root = tmp.path();
scope.spawn(move || {
barrier.wait();
for _ in 0..16 {
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
}
});
}
});
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
-32
View File
@@ -130,38 +130,6 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
if options.tool.is_none()
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
let d = ide_plugin::ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let id = id.clone();
return tokio::task::spawn_blocking(move || {
plugins.language_format(
&id,
&d,
range,
ide_plugin::language_features::FormatOptions {
tab_size: options.tab_size,
insert_spaces: options.insert_spaces,
},
)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
}
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
-30
View File
@@ -55,8 +55,6 @@ pub struct HostConfig {
}
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -101,14 +99,6 @@ pub struct Host {
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
/// Windows whose current project has this root.
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
let mut windows: Vec<_> =
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
windows.sort();
windows
}
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
@@ -125,8 +115,6 @@ impl Host {
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
trust: trust::TrustStore::load(&config_dir, &data_dir),
@@ -178,19 +166,6 @@ impl Host {
}
}
/// Associates the plugin runtime that executes this host's project operations.
/// Publishes only the executable of the running built-in MCP service.
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
*self.mcp_executable.write() = executable;
}
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
*self.plugin_host.write() = Arc::downgrade(plugins);
}
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
self.plugin_host.read().upgrade()
}
/// Every open project (all windows).
pub fn projects(&self) -> Vec<Arc<Project>> {
self.projects.read().values().cloned().collect()
@@ -256,11 +231,6 @@ impl Host {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if self.windows_for_root(&project.root).is_empty()
&& let Some(plugins) = self.plugin_host()
{
plugins.withdraw_project_run_targets(&project.root);
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
if let Some(plugins) = plugins::editor_plugin_host() {
+1 -56
View File
@@ -75,11 +75,7 @@ impl ToolRegistry for Registry {
}
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
let (_, project) = self.target(root)?;
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
if let Some(plugins) = self.0.plugin_host() {
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
}
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
Ok(tools().into_iter().map(|t| t.metadata).filter(|t| allowed(t, project.is_trusted())).collect())
}
fn call(
&self,
@@ -99,12 +95,6 @@ impl ToolRegistry for Registry {
client: &str,
) -> Result<String, ToolCallError> {
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
if let Some(plugins) = self.0.plugin_host()
&& let Some((plugin, tool, metadata)) =
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
{
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
}
let tool = tools()
.into_iter()
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
@@ -139,51 +129,6 @@ impl Host {
}
}
impl Registry {
#[allow(clippy::too_many_arguments)]
fn call_plugin(
&self,
window: String,
project: Arc<crate::Project>,
plugins: Arc<crate::plugins::PluginHost>,
plugin: String,
tool: String,
metadata: ide_mcp::Tool,
arguments: &str,
cancel: CancellationToken,
client: &str,
) -> Result<String, ToolCallError> {
let run = || -> ide_core::IdeResult<String> {
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let call = tool::Call {
host: self.0.clone(),
project: project.clone(),
window: window.clone(),
args,
cancel: cancel.clone(),
client: client.into(),
tool: metadata.name,
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if metadata.destructive {
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
}
if !metadata.read_only {
project.require_trust()?;
}
let invocation = ide_plugin::Invocation {
client: Some(window),
workspace_root: Some(project.root.clone()),
..Default::default()
};
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
};
run().map_err(|e| ToolCallError::Failed(e.message))
}
}
#[cfg(test)]
mod tests {
#[test]
+3 -35
View File
@@ -28,8 +28,6 @@ pub struct Reports(RwLock<State>);
struct State {
windows: HashMap<String, Window>,
focused: Option<String>,
/// Windows in the order they were last focused, the most recent last.
focus_order: Vec<String>,
remote: HashMap<String, PathBuf>,
libraries: HashMap<String, Vec<PathBuf>>,
}
@@ -48,16 +46,8 @@ impl Reports {
state.windows.entry(window.into()).or_default().focused = editor;
if focused {
state.focused = Some(window.into());
state.focus_order.retain(|w| w != window);
state.focus_order.push(window.into());
}
}
/// Sorts `windows` by when they were last focused, the most recent first; windows
/// never focused keep their order after them.
pub(crate) fn most_recently_focused_first(&self, windows: &mut [String]) {
let order = &self.0.read().focus_order;
windows.sort_by_key(|w| std::cmp::Reverse(order.iter().position(|o| o == w)));
}
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
let mut state = self.0.write();
let problems = &mut state.windows.entry(window.into()).or_default().problems;
@@ -78,7 +68,6 @@ impl Reports {
state.windows.remove(window);
state.remote.remove(window);
state.libraries.remove(window);
state.focus_order.retain(|w| w != window);
if state.focused.as_deref() == Some(window) {
state.focused = None;
}
@@ -102,7 +91,7 @@ impl Reports {
pub(super) fn focused_window(&self) -> Option<String> {
self.0.read().focused.clone()
}
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
pub(super) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
let state = self.0.read();
let mut paths = Vec::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
@@ -114,10 +103,10 @@ impl Reports {
}
paths
}
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
pub(super) fn focused(&self, window: &str) -> Option<FocusedEditor> {
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
}
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
pub(super) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
let state = self.0.read();
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
@@ -133,24 +122,3 @@ impl Reports {
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_sort_by_their_last_focus() {
let reports = Reports::default();
for window in ["a", "b", "a", "c"] {
reports.set_focused_editor(window, None, true);
}
reports.set_focused_editor("b", None, false);
let mut windows: Vec<String> = ["a", "b", "c", "never"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["c", "a", "b", "never"]);
reports.forget_window("c");
let mut windows: Vec<String> = ["never", "c", "b", "a"].map(String::from).into();
reports.most_recently_focused_first(&mut windows);
assert_eq!(windows, ["a", "b", "never", "c"]);
}
}
+8 -9
View File
@@ -134,22 +134,21 @@ pub async fn inspect_code(
let cancel = host.begin_request(request);
let listed = {
let (project, cancel) = (project.clone(), cancel.clone());
blocking(move || {
let files = files_in_scope(&project, &scope, &cancel)?;
let total = files.len() as u32;
let rel: std::collections::HashMap<_, _> =
files.into_iter().filter(|f| project.lsp.serves_path(&f.path)).map(|f| (f.path, f.rel_path)).collect();
Ok((total, rel))
})
.await
blocking(move || files_in_scope(&project, &scope, &cancel)).await
};
let (total_in_scope, rel) = match listed {
let files = match listed {
Ok(files) => files,
Err(e) => {
host.end_request(request);
return Err(e);
}
};
let total_in_scope = files.len() as u32;
let rel: std::collections::HashMap<_, _> = files
.iter()
.filter(|f| project.lsp.serves_path(&f.path))
.map(|f| (f.path.clone(), f.rel_path.clone()))
.collect();
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
+90 -104
View File
@@ -1,7 +1,5 @@
//! Maven / Gradle tool windows (the build model and its background sync) and
//! Build › Build Project (compiling with the project's build system).
mod workspace;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
@@ -12,6 +10,7 @@ use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
fn join_err(e: tokio::task::JoinError) -> IdeError {
@@ -126,8 +125,8 @@ fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || workspace::load_project(&project, &config)).await.map_err(join_err)?
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
}
fn linked_gradle_paths(
@@ -225,89 +224,84 @@ pub async fn build_sync(
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
// What the Java language server and Build Project resolve the project with from now on.
let profiles_changed = tool == BuildTool::Maven
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
if profiles_changed {
// The modules of the chosen profiles are read from the POMs with them from now on (the
// project tree's module folders too), whether or not Maven itself can run.
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
}
let token = host.begin_project_request(window, request);
let completion_token = token.clone();
let bridge_profiles = profiles.clone();
let p = project.clone();
let config = host.config_dir.clone();
let original_links = settings.languages.java.gradle.linked_projects.clone();
let result = tokio::task::spawn_blocking(move || {
let scopes = workspace::scopes(&p, &config)?;
let selected: Vec<_> =
scopes.iter().filter(|scope| workspace::selected(scope, &p.root, tool, build_dir.as_deref())).collect();
if selected.is_empty() {
return Err(IdeError::not_found("Build project is no longer linked"));
}
let mut profiles_changed = false;
for scope in selected {
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let (root, cache, settings) = (&scope.root, &scope.cache, &scope.settings);
let current = load(root, cache, settings);
if tool == BuildTool::Maven && current.maven.is_some() {
let changed = ide_build::save_maven_profiles(cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
if changed {
p.sink.emit(IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
}
profiles_changed |= changed;
}
let env = current
let current = load(&root, &cache, &settings);
let env = match tool {
BuildTool::Maven => current
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default();
let options = SyncOptions {
offline,
profiles: profiles.clone(),
env,
maven_command: ide_sdk::apply::maven_command(root, settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(root, settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven if current.maven.is_some() => {
ide_build::sync_maven(root, cache, &options, &token, &progress)?
.unwrap_or_default(),
BuildTool::Gradle => BTreeMap::new(),
};
let options = SyncOptions {
offline,
profiles,
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
BuildTool::Gradle => {
if let Some(dir) = &build_dir
&& !current.gradle.iter().any(|g| &g.dir == dir)
{
return Err(IdeError::not_found("Gradle project is no longer linked"));
}
BuildTool::Maven => {}
BuildTool::Gradle => {
for g in current
.gradle
.iter()
.filter(|g| build_dir.as_ref().is_none_or(|dir| p.root.join(dir) == root.join(&g.dir)))
{
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let options = SyncOptions {
env: g.env.clone(),
gradle_command: Some(g.command.clone()),
..options.clone()
};
ide_build::sync_gradle_model(root, cache, g, &options, &token, &progress)?;
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let options =
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
}
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
}
}
let scopes = workspace::scopes(&p, &config)?;
let model = workspace::load_scopes(&p.root, &scopes);
let updates = workspace::update_files(&p.root, &scopes, &model, tool);
Ok::<_, IdeError>((model, profiles_changed, updates))
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
})
.await
.map_err(join_err);
host.end_request(request);
let (model, profiles_changed, updates) = result??;
let model = result??;
if completion_token.is_cancelled() {
return Err(IdeError::cancelled());
}
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
{
// A late sync must not re-import a build that was unlinked while it ran.
return tokio::task::spawn_blocking(move || {
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
})
.await
.map_err(join_err);
}
if tool == BuildTool::Gradle {
// The sync may report project folders outside the project (`includeFlat`): index and list them.
project.refresh_index_scope();
@@ -326,17 +320,30 @@ pub async fn build_sync(
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
// not the reactor: naming the reactor here would import it again, all modules included.
let imported = match tool {
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
BuildTool::Gradle => None,
};
let uris = match imported {
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
};
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
});
Ok(model)
@@ -346,25 +353,12 @@ pub async fn build_sync(
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
project: &Project,
config: &Path,
pom: &str,
dependency: &ArtifactKey,
) -> IdeResult<(std::path::PathBuf, String, Range)> {
let scopes = workspace::scopes(project, config)?;
let path = project.root.join(pom);
let scope = scopes
.iter()
.filter(|s| path.starts_with(&s.root))
.max_by_key(|s| s.root.components().count())
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
let model = load(&scope.root, &scope.cache, &scope.settings).maven.unwrap_or_default();
let pom = model
.projects
.iter()
.find(|p| scope.root.join(&p.pom) == path)
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
for rel in ide_build::pom_edit::declaring_poms(&model, &pom.pom) {
let path = scope.root.join(&rel);
let model = ide_build::maven::load(&project.root);
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
let path = project.root.join(&rel);
let text = project.text_of(&path)?;
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
return Ok((path, text, range));
@@ -404,19 +398,14 @@ pub async fn gradle_dependencies(
) -> IdeResult<Vec<ide_build::GradleDependency>> {
host.require_trust(window)?;
let project = host.project(window)?;
let config = host.config_dir.clone();
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let scopes = workspace::scopes(&project, &config)?;
let scope = scopes
.iter()
.find(|s| workspace::selected(s, &project.root, BuildTool::Gradle, build_dir.as_deref()))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let model = load(&scope.root, &scope.cache, &scope.settings);
let model = load(&root, &cache, &settings);
let gradle = model
.gradle
.iter()
.find(|g| build_dir.as_ref().is_none_or(|dir| project.root.join(dir) == scope.root.join(&g.dir)))
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let options = SyncOptions {
offline,
@@ -424,7 +413,7 @@ pub async fn gradle_dependencies(
gradle_command: Some(gradle.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&scope.root, gradle, &options, &gradle_path, &configuration, &token)
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -440,9 +429,8 @@ pub async fn maven_dependency_declaration(
dependency: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let (path, _, range) = declaring_pom(&project, &config, &pom, &dependency)?;
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
Ok(Location::file(path, range))
})
.await
@@ -481,9 +469,8 @@ pub async fn maven_exclude_dependency(
exclusion: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let (path, text, range) = declaring_pom(&project, &config, &pom, &dependency)?;
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit = WorkspaceEdit {
@@ -552,8 +539,7 @@ pub async fn build_run(
let mut combined: Option<compile::BuildPlan> = None;
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache =
if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) };
let member_cache = cache.join(crate::project::project_key(member));
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
-262
View File
@@ -1,262 +0,0 @@
use super::*;
use std::path::PathBuf;
pub(super) struct Scope {
pub root: PathBuf,
pub cache: PathBuf,
pub settings: Settings,
}
pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>> {
scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective())
}
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
let window_root = root;
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: if root == window_root {
cache.to_owned()
} else {
cache.join(crate::project::project_key(&root))
},
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
root,
})
.collect(),
None => vec![Scope { root: root.to_owned(), cache: cache.to_owned(), settings }],
})
}
fn absolute(root: &Path, path: &str) -> String {
let path = if path.is_empty() { root.to_owned() } else { root.join(path) };
ide_core::ipc_path::to_ipc(&path)
}
pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
let mut combined = BuildModel::default();
for scope in scopes {
let mut model = load(&scope.root, &scope.cache, &scope.settings);
if scopes.len() == 1 && scope.root == root {
return model;
}
if scope.root != root
&& let Some(maven) = &mut model.maven
{
let owner = ide_core::ipc_path::to_ipc(&scope.root);
for p in &mut maven.projects {
p.project_root = Some(owner.clone());
p.pom = absolute(&scope.root, &p.pom);
p.dir = absolute(&scope.root, &p.dir);
for module in &mut p.modules {
*module = absolute(&scope.root, module);
}
}
for profile in &mut maven.profiles {
profile.project_root = Some(owner.clone());
}
}
if let Some(maven) = model.maven {
match &mut combined.maven {
None => combined.maven = Some(maven),
Some(all) => {
all.projects.extend(maven.projects);
all.profiles.extend(maven.profiles);
all.synced_at = match (all.synced_at, maven.synced_at) {
(Some(a), Some(b)) => Some(a.min(b)),
_ => None,
};
all.stale |= maven.stale;
if let Some(error) = maven.sync_error {
all.sync_error = Some(match all.sync_error.take() {
Some(previous) => format!("{previous}\n{error}"),
None => error,
});
}
}
}
}
for g in model.gradle.iter_mut().filter(|_| scope.root != root) {
g.dir = absolute(&scope.root, &g.dir);
for p in &mut g.projects {
p.dir = absolute(&scope.root, &p.dir);
p.build_file = p.build_file.as_ref().map(|f| absolute(&scope.root, f));
}
}
combined.gradle.extend(model.gradle);
}
combined
}
pub(super) fn load_project(project: &Project, config: &Path) -> IdeResult<BuildModel> {
Ok(load_scopes(&project.root, &scopes(project, config)?))
}
pub(super) fn selected(scope: &Scope, window_root: &Path, tool: BuildTool, dir: Option<&str>) -> bool {
let Some(dir) = dir else { return true };
match tool {
BuildTool::Maven => scope.root == Path::new(dir),
BuildTool::Gradle => load(&scope.root, &scope.cache, &scope.settings)
.gradle
.iter()
.any(|g| window_root.join(dir) == scope.root.join(&g.dir)),
}
}
/// Build files select their member's language server, including independent nested builds.
pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, tool: BuildTool) -> Vec<PathBuf> {
let mut files = Vec::new();
match tool {
BuildTool::Maven => {
for scope in scopes {
// A reactor with unlinked modules must not be imported as a whole again.
let imported = crate::project::jdtls_maven_import(&scope.root, &scope.settings).1;
let paths: Vec<_> = match imported {
Some(uris) => uris
.iter()
.filter_map(|u| ide_lsp::uri_to_path(u))
.filter(|p| p.file_name().is_some_and(|n| n == "pom.xml"))
.collect(),
None => model
.maven
.iter()
.flat_map(|m| &m.projects)
.filter(|p| p.project_root.as_ref().is_none_or(|owner| Path::new(owner) == scope.root))
.map(|p| root.join(&p.pom))
.collect(),
};
for path in paths {
if !files.contains(&path) {
files.push(path);
}
}
}
}
BuildTool::Gradle => {
for file in model.gradle.iter().flat_map(|g| &g.projects).filter_map(|p| p.build_file.as_ref()) {
let path = root.join(file);
if !files.contains(&path) {
files.push(path);
}
}
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attached_primary_keeps_its_cache_and_combines_build_models() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("primary");
let other = base.join("other");
for path in [&root, &other] {
std::fs::create_dir(path).unwrap();
std::fs::write(path.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
}
let cache = base.join("cache");
ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap();
ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap();
let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap();
assert_eq!(scopes[0].cache, cache);
assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]);
assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other)));
let model = load_scopes(&root, &scopes);
let projects = model.maven.unwrap().projects;
assert_eq!(projects.len(), 2);
assert_eq!(projects[0].pom, "pom.xml");
assert_eq!(projects[1].pom, absolute(&other, "pom.xml"));
}
#[test]
fn workspace_models_use_member_settings_paths_and_caches() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let workspace = base.join("workspace");
let config = base.join("config");
let cache = base.join("cache");
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
for member in &members {
std::fs::create_dir_all(member.join("extra")).unwrap();
std::fs::write(
member.join("pom.xml"),
r#"<project><artifactId>app</artifactId>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles>
</project>"#,
)
.unwrap();
std::fs::write(member.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(member.join("settings.gradle"), "rootProject.name = 'app'").unwrap();
std::fs::write(member.join("build.gradle"), "").unwrap();
}
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", members.clone(), true).unwrap();
let first_cache = cache.join(crate::project::project_key(&members[0]));
ide_build::save_maven_profiles(&first_cache, &["extra".into()]).unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&members[1]));
settings
.set(
ide_workspace::settings::SettingsScope::Project,
"languages.java.gradle.linkedProjects",
serde_json::json!([]),
)
.unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
assert_eq!(scopes[0].cache, first_cache);
let model = load_scopes(&workspace, &scopes);
let maven = model.maven.as_ref().unwrap();
assert_eq!(maven.projects.len(), 3);
assert_eq!(maven.projects[0].modules, [absolute(&members[0], "extra/pom.xml")]);
assert!(maven.projects.iter().all(|p| Path::new(&p.pom).is_absolute() && Path::new(&p.dir).is_absolute()));
assert_eq!(maven.profiles.len(), 2);
assert_ne!(maven.profiles[0].project_root, maven.profiles[1].project_root);
let updates = update_files(&workspace, &scopes, &model, BuildTool::Maven);
assert_eq!(updates.len(), 3);
assert!(updates.iter().all(|p| members.iter().any(|root| p.starts_with(root))));
assert_eq!(update_files(&workspace, &scopes, &model, BuildTool::Gradle), [members[0].join("build.gradle")]);
assert_eq!(model.gradle.len(), 1);
assert_eq!(model.gradle[0].dir, absolute(&members[0], ""));
assert!(selected(&scopes[0], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
assert!(!selected(&scopes[1], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
assert!(selected(&scopes[0], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
assert!(!selected(&scopes[1], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
assert_eq!(ide_build::maven_profiles(&scopes[1].cache), Vec::<String>::new());
let standalone = scopes_at(&members[0], &cache, &config, Settings::default()).unwrap();
assert_eq!(load_scopes(&members[0], &standalone), load(&members[0], &cache, &Settings::default()));
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", vec![members[1].clone()], false).unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
let model = load_scopes(&workspace, &scopes);
assert_eq!(model.maven.unwrap().projects.len(), 1);
assert!(model.gradle.is_empty());
std::fs::remove_dir_all(&members[1]).unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
assert_eq!(load_scopes(&workspace, &scopes), BuildModel::default());
}
#[test]
fn reload_keeps_unlinked_modules_out_of_member_language_servers() {
let temp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp.path()).unwrap();
for name in ["enabled", "hidden"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("pom.xml"),
format!("<project><artifactId>{name}</artifactId></project>"),
)
.unwrap();
}
std::fs::write(root.join("pom.xml"), "<project><artifactId>parent</artifactId><packaging>pom</packaging><modules><module>enabled</module><module>hidden</module></modules></project>").unwrap();
let mut settings = Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["hidden/pom.xml".into()];
let scope = Scope { root: root.clone(), cache: root.join("cache"), settings };
let model = load(&scope.root, &scope.cache, &scope.settings);
assert_eq!(update_files(&root, &[scope], &model, BuildTool::Maven), [root.join("enabled/pom.xml")]);
}
}
+1 -6
View File
@@ -169,12 +169,7 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
/// Replaces the project's data sources with `sources`.
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
let db = host.project(window)?.db.clone();
db.save_sources(sources).await?;
let root = host.project(window)?.root.clone();
for project in host.projects().into_iter().filter(|p| p.root == root) {
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
}
Ok(())
db.save_sources(sources).await
}
/// Remembers the password of data source `id` for the session, and in the OS
+1 -23
View File
@@ -28,7 +28,6 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
@@ -76,20 +75,8 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
config.extra_json = Some(extra.to_string());
}
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let mut ruby_tools = Vec::new();
if config.adapter == "rdbg" && config.request == "launch" {
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
let (dir, settings) = (root.clone(), settings.clone());
(config, ruby_tools) = host
.blocking(move || {
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
Ok((config, tools))
})
.await?;
}
let custom = |command: &str| {
let path = ide_sdk::apply::tool_command(&root, &settings, command)
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
Some(path.to_string_lossy().into_owned())
};
host.require_trust(window)?;
@@ -352,14 +339,6 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
}
/// A loopback port that is free right now, for a debug agent the launch line starts
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
host.require_trust(window)?;
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
pub async fn debug_listen_for_jvm(
@@ -501,7 +480,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
.await
});
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
d.add("debug_listen_for_jvm", |c| async move {
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
});
+1 -1
View File
@@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes
workspace
.projects
.into_iter()
.filter(|root| **root != project.root && path.starts_with(root))
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
});
let settings = match &member {
+1 -50
View File
@@ -52,20 +52,6 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = host.project(window)?;
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
if let Some((plugins, d)) =
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
{
let items =
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
} else {
lsp
}
} else {
lsp
};
let lsp = lsp.filter(|v| !v.is_empty());
blocking(move || {
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
@@ -94,27 +80,7 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
if snap.large {
return Ok(Vec::new());
}
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
let d = ide_plugin::ProviderDocument {
path: project.documents.identity(doc)?.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
start_line: f.start_line,
end_line: f.end_line,
kind: match f.kind.as_str() {
"comment" => ide_language::FoldKind::Comment,
"imports" => ide_language::FoldKind::Imports,
_ => ide_language::FoldKind::Region,
},
}));
folds.sort_by_key(|f| (f.start_line, f.end_line));
folds.dedup_by_key(|f| (f.start_line, f.end_line));
}
Ok(folds)
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
})
.await
}
@@ -139,21 +105,6 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if !snap.large
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
{
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
let d = ide_plugin::ProviderDocument {
path: project.documents.identity(doc)?.0,
language: snap.language.0.clone(),
version: u64::from(snap.version),
text: snap.text.clone(),
};
let ranges = plugins.language_selection_ranges(&d, at);
if !ranges.is_empty() {
return Ok(ranges);
}
}
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
})
+12 -71
View File
@@ -8,7 +8,6 @@ use super::build::load;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
use ide_run::BuildPolicy;
use std::path::PathBuf;
/// Cancellable with `cancel_request(request)`.
@@ -20,12 +19,12 @@ pub async fn java_resolve_launch(
window: &str,
module: PathBuf,
test: bool,
policy: BuildPolicy,
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
host.require_trust(window)?;
let cancel = host.begin_project_request(window, request);
let result = resolve_launch(host, window, module, test, policy, &cancel).await;
let result = resolve_launch(host, window, module, test, build, &cancel).await;
host.end_request(request);
result
}
@@ -35,17 +34,13 @@ async fn resolve_launch(
window: &str,
module: PathBuf,
test: bool,
policy: BuildPolicy,
build: bool,
cancel: &CancellationToken,
) -> IdeResult<JavaLaunchInfo> {
let started = std::time::Instant::now();
let project = host.project(window)?;
let module = project.root.join(module);
let settings = project.settings.effective();
// A launch builds incrementally unless the configuration asks for Rebuild or NoBuild.
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
@@ -64,7 +59,7 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
};
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
@@ -75,23 +70,17 @@ async fn resolve_launch(
&project.root,
&model,
&module,
cache::BuildOptions { test, policy },
test,
ide_sdk::apply::maven_local_repository(&project.root, &settings),
&cancel,
)?;
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
apply_external_build_result(&mut info, outputs.failure);
info.build = Some(JavaBuildStatus::Succeeded);
info.errors.clear();
info.error_count = 0;
copies
} else if external {
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
if let Some(failure) = built.failure {
if policy != BuildPolicy::MakeAndContinueOnErrors {
return Err(IdeError::internal(failure));
}
info.build = Some(JavaBuildStatus::Failed);
info.hints.push(failure);
}
built.class_dirs
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
} else {
Vec::new()
};
@@ -149,18 +138,6 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
JavaBuildStatus::Failed
} else if info.error_count > 0 {
// Incremental Maven compilation can accept error-stub classes written by JDT.
JavaBuildStatus::WithErrors
} else {
JavaBuildStatus::Succeeded
});
}
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
@@ -258,63 +235,27 @@ pub async fn java_build_workspace(
host: &Host,
window: &str,
module: PathBuf,
request: Option<RequestId>,
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
// HotSwap may cancel the compile (the request id is allocated by the caller).
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
let result = project.lsp.java_build(&module, false, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
result
project.lsp.java_build(&module, &CancellationToken::new()).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("java_resolve_launch", |c| async move {
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("policy")?, c.arg("request")?)
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
.await
});
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
d.add("java_build_workspace", |c| async move {
java_build_workspace(&c.host, &c.window, c.arg("module")?, c.arg("request")?).await
});
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn successful_external_compilation_does_not_erase_scoped_jdt_errors() {
for (failure, expected) in
[(None, JavaBuildStatus::WithErrors), (Some("compile failed"), JavaBuildStatus::Failed)]
{
let mut info = JavaLaunchInfo {
error_count: 1,
errors: vec![ide_lsp::jdtls::JavaCompileError {
path: "src/main/java/Main.java".into(),
line: 4,
column: 28,
message: "missingName cannot be resolved to a variable".into(),
}],
..Default::default()
};
let errors = info.errors.clone();
apply_external_build_result(&mut info, failure.map(str::to_owned));
assert_eq!(info.build, Some(expected));
assert_eq!(info.error_count, 1);
assert_eq!(info.errors, errors);
assert_eq!(info.hints, failure.into_iter().collect::<Vec<_>>());
}
let mut info = JavaLaunchInfo::default();
apply_external_build_result(&mut info, None);
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
}
#[test]
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
use ide_core::settings::{DependencyScope, ProjectLibrary};
+21 -189
View File
@@ -1,7 +1,6 @@
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
use ide_build::compile::{self, LaunchFingerprint};
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_run::BuildPolicy;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
@@ -12,8 +11,6 @@ pub(super) struct Outputs {
fingerprint: Option<LaunchFingerprint>,
pub snapshot: PathBuf,
pub originals: Vec<PathBuf>,
#[serde(skip)]
pub failure: Option<String>,
}
impl Outputs {
@@ -38,12 +35,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
}
#[derive(Default)]
pub(super) struct BuildOptions {
pub test: bool,
pub policy: BuildPolicy,
}
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
pub(crate) struct Session {
cache: PathBuf,
@@ -70,12 +61,11 @@ impl Session {
root: &Path,
model: &ide_build::BuildModel,
module: &Path,
options: BuildOptions,
test: bool,
local_repo: Option<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<Outputs> {
self.initialize();
let BuildOptions { test, policy } = options;
let cache = &self.cache;
let record =
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
@@ -88,30 +78,9 @@ impl Session {
Ok(None)
}
};
let mut before = fingerprint()?;
let before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
// Timestamp-incremental builders (Maven, the Kotlin plugin) skip sources whose
// mtime did not advance. An external edit that preserves both length and mtime
// invalidates the fingerprint but would still compile to nothing below, and the
// stale outputs would be re-recorded as fresh. Re-stamp those sources first so
// the rebuild sees them; then re-scan so the record written afterwards matches.
let restamp: Vec<PathBuf> = match (before.as_ref(), previous.as_ref().and_then(|o| o.fingerprint.as_ref())) {
(Some(current), Some(recorded)) => recorded.mtime_preserved_edits(current),
_ => Vec::new(),
};
if !restamp.is_empty() {
let now = std::time::SystemTime::now();
for rel in &restamp {
let path = root.join(rel);
if let Err(error) = fs::File::options().write(true).open(&path).and_then(|file| file.set_modified(now))
{
tracing::debug!(path = %path.display(), %error, "Cannot re-stamp externally edited source");
}
}
before = fingerprint()?;
}
if let Some(outputs) = previous.as_ref()
&& policy != BuildPolicy::Rebuild
&& outputs.reusable(&before, &snapshots)
{
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
@@ -121,27 +90,10 @@ impl Session {
// source still delegates to Maven to clean its old classes on the next launch.
fs::create_dir_all(record.parent().unwrap())?;
fs::write(&record, b"null")?;
if let Some(previous) = &previous {
self.unreferenced.push(previous.snapshot.clone());
if let Some(previous) = previous {
self.unreferenced.push(previous.snapshot);
}
let kind = if policy == BuildPolicy::Rebuild { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let built = compile::build_for_launch(root, model, module, test, kind, cancel)?;
if let Some(failure) = &built.failure {
if policy != BuildPolicy::MakeAndContinueOnErrors {
return Err(IdeError::internal(failure.clone()));
}
if let Some(mut previous) =
previous.filter(|p| (0..p.originals.len()).all(|i| p.snapshot.join(i.to_string()).is_dir()))
{
previous.fingerprint = None;
previous.failure = built.failure;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
serde_json::to_writer(file.as_file_mut(), &previous).map_err(|e| IdeError::internal(e.to_string()))?;
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
return Ok(previous);
}
}
let originals = built.class_dirs;
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
fs::create_dir_all(&snapshots)?;
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
// Retain every new snapshot even if copying or publishing its record fails.
@@ -156,8 +108,8 @@ impl Session {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
if outputs.failure.is_none() && before.is_some() && before == after {
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
if before.is_some() && before == after {
outputs.fingerprint = before;
fs::create_dir_all(record.parent().unwrap())?;
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
@@ -247,93 +199,13 @@ mod tests {
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
/// Like [`fixture`], but the build script skips sources whose mtime did not advance,
/// mirroring Maven's (and the Kotlin plugin's) timestamp-incremental compilation.
fn fixture_timestamp_incremental(root: &Path) -> ide_build::BuildModel {
fs::write(
root.join("pom.xml"),
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\nif [ ! -f target/classes/Main.class ] || [ src/main/kotlin/Main.kt -nt target/classes/Main.class ]; then\ncp src/main/kotlin/Main.kt target/classes/Main.class\nfi\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
#[test]
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cancel = CancellationToken::new();
let mut session = Session::new(root.join("cache"));
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let rebuilt = session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::Rebuild, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_ne!(first.snapshot, rebuilt.snapshot);
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 2);
assert!(fs::read_to_string(root.join("calls")).unwrap().lines().last().unwrap().contains("clean"));
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
let continued = session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(continued.snapshot, rebuilt.snapshot);
assert!(continued.failure.unwrap().contains("Build failed"));
assert_eq!(fs::read_to_string(continued.snapshot.join("0/Main.class")).unwrap(), "first");
let again = session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
None,
&cancel,
)
.unwrap();
assert_eq!(again.snapshot, rebuilt.snapshot);
assert!(again.failure.is_some());
cancel.cancel();
assert_eq!(
session
.build(
root,
&model,
root,
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
None,
&cancel
)
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Cancelled
);
}
#[test]
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -342,7 +214,7 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let mut build = || session.build(root, &model, root, BuildOptions::default(), None, &cancel);
let mut build = || session.build(root, &model, root, false, None, &cancel);
let first = build().unwrap();
let snapshot = first.snapshot.clone();
drop(first);
@@ -381,57 +253,18 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn restart_after_mtime_preserving_external_edit_actually_recompiles() {
// The content hash triggers a rebuild for a same-length, same-mtime external
// edit, but a timestamp-incremental builder (real Maven) would still compile
// nothing and the stale snapshot would be re-recorded as fresh. Dropping the
// session simulates the IDE restart from the report.
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture_timestamp_incremental(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
// Pin the old output's mtime so the `-nt` check below is deterministic even on
// coarse-granularity filesystems.
fs::File::options()
.write(true)
.open(root.join("target/classes/Main.class"))
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42))
.unwrap();
drop(session);
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let mut session = Session::new(cache.clone());
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert_eq!(
fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(),
"other",
"the re-stamped source must actually recompile instead of re-recording stale outputs"
);
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
@@ -440,9 +273,9 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
let second = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
// Another mode can keep a superseded snapshot alive.
@@ -453,10 +286,10 @@ mod tests {
assert!(first.snapshot.is_dir());
assert!(second.snapshot.is_dir());
let mut session = Session::new(cache.clone());
let reused = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(reused.snapshot, second.snapshot);
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
let third = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
assert!(second.snapshot.is_dir());
drop(session);
assert!(!second.snapshot.exists());
@@ -483,7 +316,7 @@ mod tests {
fs::write(snapshots.join("launch-file"), "keep").unwrap();
let record = cache.join("java-launch/records/deep/module/test.json");
fs::create_dir_all(record.parent().unwrap()).unwrap();
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
let mut session = Session::new(cache);
session.initialize();
@@ -514,7 +347,7 @@ mod tests {
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
assert!(session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).is_err());
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
assert_eq!(session.unreferenced.len(), 1);
let snapshot = session.unreferenced[0].clone();
assert!(snapshot.join("0/Main.class").is_file());
@@ -533,8 +366,7 @@ mod tests {
fs::write(script, text).unwrap();
let cache = root.join("cache");
let mut session = Session::new(cache.clone());
let outputs =
session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).unwrap();
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
assert!(outputs.fingerprint.is_none());
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
assert!(outputs.snapshot.join("0/Main.class").is_file());
@@ -549,14 +381,14 @@ mod tests {
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
assert!(has_record(root, &cache, root));
assert!(session.build(root, &model, root, BuildOptions::default(), None, &cancel).is_err());
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
assert!(has_record(root, &cache, root));
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
let restored = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(first.snapshot, restored.snapshot);
}
}
+37 -652
View File
@@ -4,39 +4,11 @@ use crate::apply_workspace_edit as apply_edit;
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
use crate::rpc::Dispatcher;
use ide_core::error::ErrorKind;
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
/// Select an embedded server only for point requests; formatting and rename keep the host.
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
let lsp = host.project(window)?.lsp.clone();
lsp.refresh_injections(doc).await?;
Ok(lsp.injected_document(doc, pos))
}
async fn begin_position_request(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
let cancel = host.begin_request(request);
let target = tokio::select! {
result = position_document(host, window, doc, pos) => result,
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
};
match target {
Ok(target) => Ok((target, cancel)),
Err(e) => {
host.end_request(request);
Err(e)
}
}
}
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
@@ -69,8 +41,8 @@ pub async fn lsp_completion(
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
@@ -84,7 +56,7 @@ pub async fn lsp_completion(
}
/// The plugin host and the document, when some plugin provides `kind`.
pub(crate) fn plugin_providers(
fn plugin_providers(
host: &Host,
window: &str,
doc: DocId,
@@ -102,48 +74,10 @@ pub(crate) fn plugin_providers(
}
/// Plugin calls block (the plugin's actor thread does the work).
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
match server {
Ok(v) => Ok(v),
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
Err(e) => Err(e),
}
}
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
for l in plugin {
let loc = Location::file(l.path, l.range);
if seen.insert(loc.clone()) {
server.push(loc);
}
}
server
}
fn merge_language_highlights(
mut server: Vec<Highlight>,
plugin: Vec<ide_plugin::language_features::Highlight>,
) -> Vec<Highlight> {
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
for h in plugin {
if seen.insert(h.range) {
server.push(Highlight {
range: h.range,
kind: match h.kind.as_str() {
"read" => HighlightKind::Read,
"write" => HighlightKind::Write,
_ => HighlightKind::Text,
},
});
}
}
server
}
/// Items from plugins carry no server: commands don't apply, and a code
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
const PLUGIN_SERVER: ServerId = ServerId(0);
@@ -197,7 +131,6 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
CompletionItem {
label: item.label,
label_detail: None,
label_description: None,
detail: item.detail,
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
@@ -267,14 +200,14 @@ pub async fn lsp_hover(
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(target, pos, cancel).await;
let result = lsp.hover(doc, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
@@ -289,28 +222,11 @@ pub async fn lsp_hover(
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.signature_help(target, pos).await;
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
return server;
}
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
return server;
};
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
match help {
Some(h) => {
let _ = provider_server(server)?;
Ok(Some(crate::plugins::language_features::signature_help(h)))
}
None => server,
}
host.project(window)?.lsp.signature_help(doc, pos).await
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
if target == doc
&& kind == GotoKind::Definition
if kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
@@ -321,24 +237,7 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
return result;
};
let locations = plugin_call(move || {
if kind == GotoKind::Implementation {
plugins.language_implementation(&d, pos)
} else {
plugins.language_type_definition(&d, pos)
}
})
.await
.unwrap_or_default();
return Ok(merge_language_locations(provider_server(result)?, locations));
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
if kind != GotoKind::Definition {
return result;
}
@@ -372,26 +271,6 @@ fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapsh
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
}
fn identifier_at(
structure: &ide_syntax::StructureService,
snap: &ide_syntax::Snapshot,
range: Range,
cancel: &ide_core::CancellationToken,
) -> bool {
let Ok(Some(at)) = structure.node_at(snap, range, cancel) else { return false };
at.node.kind.contains("identifier")
|| matches!(
at.node.kind.as_str(),
"name"
| "constant"
| "label_name"
| "variable_name"
| "instance_variable"
| "class_variable"
| "global_variable"
)
}
fn definition_targets(
server: IdeResult<Vec<Location>>,
plugin: Vec<Location>,
@@ -456,61 +335,9 @@ pub async fn lsp_implementations(
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let (_, cancel) =
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
for (index, pos) in positions.iter().copied().enumerate() {
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
}
if groups.is_empty() {
groups.insert(doc, Vec::new());
}
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
let (lsp, cancel) = (lsp.clone(), cancel.clone());
async move {
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
(entries, lsp.implementations(target, &positions, cancel).await)
}
}))
.await;
let mut out = vec![Vec::new(); positions.len()];
let mut error = None;
let mut succeeded = false;
for (entries, result) in results {
match result {
Ok(found) => {
succeeded = true;
for ((index, _), locations) in entries.into_iter().zip(found) {
out[index] = locations;
}
}
Err(e) => {
error.get_or_insert(e);
}
}
}
let result = if cancel.is_cancelled() {
Err(ide_core::IdeError::cancelled())
} else if succeeded {
Ok(out)
} else {
Err(error.expect("at least one request"))
};
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
let found = plugin_call(move || {
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
})
.await
.unwrap_or_default();
let mut out = provider_server(result)?;
out.resize_with(count, Vec::new);
for (items, found) in out.iter_mut().zip(found) {
*items = merge_language_locations(std::mem::take(items), found);
}
return Ok(out);
}
result
}
@@ -526,17 +353,13 @@ pub async fn lsp_goto_implementation(
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(target, pos, cancel, &|locs| {
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
return Ok(merge_language_locations(provider_server(result)?, found));
}
result
}
@@ -549,82 +372,6 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Whether this class belongs to the project's Maven local repository.
pub async fn lsp_can_download_sources(host: &Host, window: &str, uri: String) -> IdeResult<bool> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(false);
}
let Some(jar) = ide_lsp::LspManager::library_archive(&uri) else { return Ok(false) };
host.blocking(move || {
let settings = project.settings.effective();
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo);
Ok(repository.is_some_and(|repo| ide_build::maven::sources::artifact(&jar, &repo).is_some())
&& ide_build::maven::has_projects(&project.root, &settings.languages.java.maven))
})
.await
}
/// Download with the project's Maven configuration, or attach an archive selected by the user.
pub async fn lsp_library_sources(
host: &Host,
window: &str,
request: RequestId,
uri: String,
pos: Pos,
sources: Option<std::path::PathBuf>,
) -> IdeResult<Option<Location>> {
host.require_trust(window)?;
let project = host.project(window)?;
let cancel = host.begin_project_request(window, request);
let result = async {
let sources = match sources {
Some(sources) => Some(sources),
None => {
let jar = ide_lsp::LspManager::library_archive(&uri)
.ok_or_else(|| IdeError::invalid("not a library class"))?;
let project = project.clone();
let cancel = cancel.clone();
host.blocking(move || {
let settings = project.settings.effective();
let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings)
.or_else(ide_build::maven::default_local_repo)
.ok_or_else(|| IdeError::unavailable("Maven local repository is unavailable"))?;
let artifact = ide_build::maven::sources::artifact(&jar, &repository)
.ok_or_else(|| IdeError::invalid("not an artifact in the Maven local repository"))?;
let model = super::build::load(&project.root, &project.build_cache_dir(), &settings);
let maven = model.maven.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
let owns = |p: &&ide_build::maven::MavenProject| {
p.dependencies.iter().any(|d| d.group_id == artifact.group && d.artifact_id == artifact.name)
};
let mut owner = maven
.projects
.iter()
.find(owns)
.or_else(|| maven.projects.first())
.cloned()
.ok_or_else(|| IdeError::unavailable("no Maven project"))?;
let profiles =
settings.languages.java.maven.profiles(&ide_build::maven_profiles(&project.build_cache_dir()));
if !profiles.is_empty() {
owner.options.push(format!("-P{}", profiles.join(",")));
}
ide_build::maven::sources::download(&project.root, &owner, &artifact, &repository, &cancel)
})
.await?
}
};
match sources {
Some(sources) => project.lsp.library_source_location(&uri, sources, pos, &cancel).await,
None => Ok(None),
}
}
.await;
host.end_request(request);
result
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
let archive = ide_vfs::split_archive_path(&path);
@@ -633,8 +380,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
@@ -651,9 +398,9 @@ pub async fn lsp_references(
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let cancel = host.begin_request(request);
let result = async {
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
let symbol = indexed_symbol(host, window, doc, pos).await?;
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
@@ -663,7 +410,7 @@ pub async fn lsp_references(
}
let result = lsp
.references_streaming(
target,
doc,
pos,
include_declaration,
project_wide,
@@ -738,11 +485,10 @@ fn validate_indexed_symbol(
let stale = || {
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
};
let path = dunce::simplified(&file.path);
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
return Err(stale());
}
let text = if let Some(doc) = project.documents.by_path(path) {
let text = if let Some(doc) = project.documents.by_path(&file.path) {
let snapshot = project.documents.text_snapshot(doc)?;
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
return Err(stale());
@@ -751,7 +497,7 @@ fn validate_indexed_symbol(
} else {
let loaded = project
.documents
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.map_err(|_| stale())?;
if loaded.binary || loaded.large {
return Err(stale());
@@ -789,13 +535,7 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
return server;
};
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
Ok(merge_language_highlights(provider_server(server)?, items))
host.project(window)?.lsp.document_highlights(doc, pos).await
}
pub async fn lsp_workspace_symbols(
@@ -821,9 +561,7 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let target = position_document(host, window, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_actions(target, range, only.clone()).await;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
@@ -839,7 +577,7 @@ pub async fn lsp_code_actions(
let actions = if plugin_actions.is_empty() {
result?
} else {
let mut out = provider_server(result)?;
let mut out = result.unwrap_or_default();
out.extend(plugin_actions);
out
};
@@ -863,8 +601,6 @@ fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> C
.and_then(|r| serde_json::to_value(r).ok());
CodeAction {
title: a.title,
diagnostics: Vec::new(),
original_diagnostics: None,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason,
@@ -886,13 +622,12 @@ fn plugin_file_edits(
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
match operation {
FileOperation::Create { path, text, overwrite } => {
let path = writable(path)?;
let path = confined(path)?;
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
if !text.is_empty() {
let start = Range::point(Pos::new(0, 0));
@@ -913,7 +648,7 @@ fn plugin_file_edits(
}
}
for (path, edits) in &a.edits {
let path = if confine { writable(path)? } else { path.clone() };
let path = if confine { confined(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
@@ -963,9 +698,8 @@ pub async fn lsp_code_action_query(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_action_query(target, range, only, cancel).await;
let cancel = host.begin_request(request);
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
host.end_request(request);
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
@@ -1054,12 +788,6 @@ async fn is_client_follow_up(
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
let project = host.project(window)?;
if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range, None).await?
&& let Err(e) = project.lsp.prepare_rename(doc, pos).await
&& indexed_lsp_error(e)?.is_none()
{
return Err(refusal);
}
return blocking(move || {
validate_indexed_symbol(&project, &symbol, None)?;
Ok(Some(symbol.range))
@@ -1155,15 +883,7 @@ pub async fn lsp_rename(
let mut renamed = match result {
Ok(renamed) => renamed,
Err(e) if symbol.is_some() && !native_owned => {
let incomplete = indexed_lsp_error(e)?;
if incomplete.is_none()
&& let Some(symbol) = &symbol
&& let Some(refusal) =
indexed_code_identifier(host, window, doc, symbol.range, Some(cancel.clone())).await?
{
return Err(refusal);
}
Renamed { incomplete, ..Default::default() }
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
}
Err(e) => return Err(e),
};
@@ -1201,39 +921,6 @@ pub async fn lsp_rename(
result
}
async fn indexed_code_identifier(
host: &Host,
window: &str,
doc: DocId,
range: Range,
cancel: Option<ide_core::CancellationToken>,
) -> IdeResult<Option<ide_core::IdeError>> {
let project = host.project(window)?;
let cancel = cancel.unwrap_or_default();
blocking(move || {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(None);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let identifier = identifier_at(&project.structure, &s, range, &cancel);
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
if !identifier {
return Ok(None);
}
let language = project.languages.get(&snap.language).map_or_else(|| snap.language.to_string(), |l| l.name);
Ok(Some(ide_core::IdeError::invalid(format!(
"The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed"
))))
})
.await
}
fn merge_indexed_rename(
renamed: &mut Renamed,
symbol: &crate::plugins::navigation::IndexedSymbol,
@@ -1351,52 +1038,12 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
}
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
let server = host.project(window)?.lsp.code_lens(doc).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
return server;
};
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
let mut out = provider_server(server)?;
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
range: i.range,
command: Some(Command {
title: i.title,
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
}),
}));
Ok(out)
host.project(window)?.lsp.code_lens(doc).await
}
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
let server = host.project(window)?.lsp.document_links(doc).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
return server;
};
let root = host.project(window)?.root.clone();
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
let mut out = provider_server(server)?;
out.extend(items.into_iter().filter_map(|i| {
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
if !url && ide_core::is_virtual_uri(&i.target) {
return None;
}
Some(DocumentLink {
range: i.range,
path: (!url).then(|| {
{
let path = std::path::PathBuf::from(&i.target);
if path.is_absolute() { path } else { root.join(path) }
}
.into()
}),
url: url.then_some(i.target),
fragment: None,
tooltip: None,
})
}));
Ok(out)
host.project(window)?.lsp.document_links(doc).await
}
pub async fn lsp_call_hierarchy(
@@ -1486,34 +1133,6 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
let (plugin, command_id) =
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
let plugins = crate::plugins::editor_plugin_host()
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
let argument = command
.arguments
.first()
.and_then(|a| a.as_str())
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
.flatten();
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let invocation = ide_plugin::Invocation {
client: Some(window.into()),
workspace_root: Some(project.root.clone()),
editor: Some(ide_plugin::EditorContext {
path: snap.path.0.clone(),
language: snap.language.0,
selections: vec![],
}),
selected_paths: vec![snap.path.0],
..Default::default()
};
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
.await;
}
host.project(window)?.lsp.execute_command(doc, command).await
}
@@ -1576,88 +1195,7 @@ pub async fn lsp_execute_code_action(
}
/// Commands of this module the remote server answers.
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginLanguageVisuals {
pub version: u64,
pub injections: Vec<ide_plugin::language_features::Injection>,
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
}
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let generation = project.lsp.injection_generation();
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
let mut out =
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
out.injections = plugins.provide_injections(&d);
out.colors = plugins.language_colors(&d);
out.gutter_actions = plugins.language_gutter_actions(&d);
}
project.lsp.set_injections_at(
doc,
d.version as u32,
out.injections
.iter()
.map(|i| ide_lsp::InjectedRange {
range: i.range,
language: ide_core::LanguageId::new(i.language.clone()),
})
.collect(),
generation,
);
Ok(out)
})
.await
}
pub async fn plugin_color_presentations(
host: &Host,
window: &str,
doc: DocId,
plugin: String,
color: ide_plugin::language_features::ColorInfo,
) -> IdeResult<Vec<String>> {
let snap = host.project(window)?.documents.snapshot(doc)?;
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
language: snap.language.0,
version: u64::from(snap.version),
text: snap.text,
};
if !crate::plugins::language_features::valid_range(&d, color.range) {
return Err(ide_core::IdeError::invalid("Invalid color range"));
}
Ok(crate::plugins::editor_plugin_host()
.map(|p| p.language_color_presentations(&plugin, &d, color))
.unwrap_or_default())
})
.await
}
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add(
"plugin_language_visuals",
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
);
d.add("plugin_color_presentations", |c| async move {
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
});
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
d.add("lsp_completion", |c| async move {
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
});
@@ -1690,14 +1228,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
);
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add(
"lsp_can_download_sources",
|c| async move { lsp_can_download_sources(&c.host, &c.window, c.arg("uri")?).await },
);
d.add("lsp_library_sources", |c| async move {
lsp_library_sources(&c.host, &c.window, c.arg("request")?, c.arg("uri")?, c.arg("pos")?, c.arg("sources")?)
.await
});
d.add("lsp_references", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_references(
@@ -1811,32 +1341,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
#[cfg(test)]
mod tests {
#[test]
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
use super::*;
let range = Range::new(Pos::default(), Pos::new(0, 1));
let existing = Location::file("/p/a".into(), range);
let merged = merge_language_locations(
vec![existing.clone()],
vec![
ide_plugin::Location { path: "/p/a".into(), range },
ide_plugin::Location { path: "/p/b".into(), range },
],
);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0], existing);
let merged = merge_language_highlights(
vec![Highlight { range, kind: HighlightKind::Read }],
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].kind, HighlightKind::Read);
assert!(
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
);
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
}
use super::*;
#[test]
@@ -1859,13 +1363,11 @@ mod tests {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let root = tmp.path().canonicalize().unwrap();
let shared = root.join("shared.idx");
let native = root.join("native.txt");
std::fs::write(&shared, "old old").unwrap();
std::fs::write(&native, "old").unwrap();
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
let shared = std::fs::canonicalize(shared).unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
let symbol = IndexedSymbol {
@@ -1877,6 +1379,8 @@ mod tests {
ranges: vec![range, other],
}],
};
// Native file URIs have ordinary Windows paths; index snapshots retain
// canonical verbatim paths. Both identify the same file and edit batch.
let server_shared = dunce::simplified(&shared).to_path_buf();
let server_native = dunce::simplified(&native).to_path_buf();
#[cfg(windows)]
@@ -2103,76 +1607,6 @@ mod tests {
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
}
#[test]
fn indexed_names_on_code_identifiers_are_told_apart_from_strings_and_comments() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let cancel = ide_core::CancellationToken::new();
let anchor = |text: &str, line: u32, name: &str| {
let col = text.lines().nth(line as usize).unwrap().find(name).unwrap() as u32;
Range::new(Pos::new(line, col), Pos::new(line, col + name.len() as u32))
};
for (language, text, name) in [
("java", "class Order {}", "Order"),
("kotlin", "class Order", "Order"),
("typescript", "class Order {}", "Order"),
("python", "class Order:\n pass", "Order"),
("go", "package app\ntype Order struct {}", "Order"),
("rust", "struct Order {}", "Order"),
("cpp", "class Order {};", "Order"),
("go", "package app\nfunc f() { retry: goto retry }", "retry"),
("ruby", "@order = 1", "@order"),
("ruby", "@@order = 1", "@@order"),
("ruby", "$order = 1", "$order"),
("php", "<?php $order = 1;", "$order"),
] {
let language = ide_core::LanguageId::new(language);
let snap = ide_syntax::Snapshot::text(&language, text);
let line = u32::from(text.starts_with("package"));
assert!(identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
}
for (language, text, name) in [
("java", "class App { String route = \"orders.index\"; }", "orders.index"),
("kotlin", "val route = \"orders.index\"", "orders.index"),
("typescript", "const route = \"orders.index\";", "orders.index"),
("python", "route = \"orders.index\"", "orders.index"),
("go", "package app\nvar route = \"orders.index\"", "orders.index"),
("rust", "const ROUTE: &str = \"orders.index\";", "orders.index"),
("cpp", "const char* route = \"orders.index\";", "orders.index"),
("json", "{\"orders.index\": \"orders.index\"}", "orders.index"),
("yaml", "orders.index: orders.index", "orders.index"),
("xml", "<select id=\"orders.index\" />", "id"),
] {
let language = ide_core::LanguageId::new(language);
let snap = ide_syntax::Snapshot::text(&language, text);
let line = u32::from(text.starts_with("package"));
assert!(!identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
}
let php = ide_core::LanguageId::new("php");
let text = "<?php\nuse App\\Models\\Order;\nclass Order {}\n// Order is a model\n\
return route('users.show', Order::query());\n";
let snap = ide_syntax::Snapshot::text(&php, text);
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order"), &cancel), "an imported class");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a class declaration");
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order"), &cancel), "a static call's class");
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show"), &cancel), "a route name");
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order"), &cancel), "a comment");
let ruby = ide_core::LanguageId::new("ruby");
let text = "class Order\nend\nOrder.where(status: \"paid\")\n";
let snap = ide_syntax::Snapshot::text(&ruby, text);
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order"), &cancel), "a Ruby class name");
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a Ruby constant reference");
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid"), &cancel), "a Ruby string");
let xml = ide_core::LanguageId::new("xml");
let text = "<mapper>\n <select id=\"selectById\">select 1</select>\n</mapper>\n";
let snap = ide_syntax::Snapshot::text(&xml, text);
assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById"), &cancel), "a MyBatis statement id");
let idx = ide_core::LanguageId::new("idx");
let text = "def route\n";
let snap = ide_syntax::Snapshot::text(&idx, text);
assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route"), &cancel), "a language without a grammar");
}
#[test]
fn generated_ide_helpers_give_way_to_real_declarations() {
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
@@ -2193,55 +1627,6 @@ mod tests {
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[cfg(unix)]
#[test]
fn plugins_file_actions_confine_content_writes_and_preserve_link_entries() {
use ide_core::FileEdit;
use ide_plugin::{CodeAction, FileOperation};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::fs::write(root.join("target.txt"), "inside").unwrap();
for (name, target) in [
("outside", outside.clone()),
("dangling", tmp.path().join("missing.txt")),
("inside", root.join("target.txt")),
] {
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
}
let action = |operations, edits| CodeAction {
title: "Update files".into(),
kind: None,
preferred: false,
edits,
operations,
open: Some(root.join("target.txt")),
data: None,
file_action: true,
};
for name in ["outside", "dangling"] {
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
}
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
let operations = vec![
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
FileOperation::Delete { path: root.join("outside") },
];
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Rename { from, to }
if **from == root.join("outside") && **to == root.join("dangling")));
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
}
#[test]
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
use ide_core::FileEdit;
@@ -2249,7 +1634,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(&root).unwrap();
let root = dunce::canonicalize(&root).unwrap();
let root = root.canonicalize().unwrap();
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
title: "Create view".into(),
kind: Some("quickfix".into()),
-6
View File
@@ -41,11 +41,6 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult<Vec<IpcPath>> {
let project = host.project(window)?;
blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
@@ -172,7 +167,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
+20 -61
View File
@@ -13,8 +13,6 @@ pub struct ProjectInfo {
pub trusted: bool,
pub root: ide_core::IpcPath,
pub name: String,
#[serde(default)]
pub display_name: String,
pub is_git: bool,
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -26,23 +24,17 @@ pub struct ProjectInfo {
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
let primary = if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
};
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
ProjectInfo {
trusted: project.is_trusted(),
display_name: workspace
.as_ref()
.map(|w| w.display_name(&project.root, &primary))
.unwrap_or_else(|| primary.clone()),
name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary),
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
workspace,
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -71,71 +63,38 @@ pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
let project = host.project(window)?;
let previous = ide_workspace::workspace::Workspace::load(&project.root)?
.ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?;
for path in paths.iter().filter(|p| !previous.projects.contains(p)) {
require_attachment_trust(host, path)?;
}
if !previous.is_container(&project.root) && paths.first() != Some(&project.root) {
return Err(ide_core::IdeError::invalid("The primary project cannot be detached"));
}
if !previous.is_container(&project.root) && paths == [project.root.clone()] {
std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?;
} else {
let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
keep_attachment_private(&project.root, &workspace)?;
}
refresh_workspace(&project);
Ok(info(&project))
}
fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> {
if !host.trust.trusted(path)? {
return Err(ide_core::IdeError::invalid("Trust the project before attaching it"));
}
Ok(())
}
fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> {
if !workspace.is_container(root) {
ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?;
}
Ok(())
}
fn refresh_workspace(project: &Project) {
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
project.settings.reload();
project.refresh_index_scope();
project.refresh_repositories();
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
Ok(info(&project))
}
/// Attaches a trusted folder without replacing the current project.
/// Links a folder, or prepares an automatic workspace for the caller to open in place.
pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult<ide_core::IpcPath> {
let project = host.project(window)?;
let path = dunce::canonicalize(path)?;
if project.light {
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
}
if path == project.root {
return Ok(project.root.clone().into());
}
require_attachment_trust(host, &path)?;
if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
if !workspace.projects.contains(&path) {
workspace.projects.push(path);
save_workspace(host, window, &workspace.name, workspace.projects)?;
}
} else {
let workspace = ide_workspace::workspace::Workspace::attach(
&project.root,
&info(&project).name,
vec![project.root.clone(), path],
)?;
keep_attachment_private(&project.root, &workspace)?;
refresh_workspace(&project);
return Ok(project.root.clone().into());
}
Ok(project.root.clone().into())
if path == project.root {
return Ok(project.root.clone().into());
}
Ok(ide_workspace::workspace::Workspace::create_for_project(
&host.data_dir,
&project.root,
&info(&project).name,
&path,
)?
.into())
}
/// What [`set_settings`] changed; the caller tells the windows.
+16 -109
View File
@@ -9,9 +9,6 @@ use std::time::Duration;
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
host.require_trust(window)?;
let project = host.project(window)?;
if let Some(plugins) = host.plugin_host() {
plugins.refresh_run_configurations(&project.root);
}
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
}
@@ -24,7 +21,7 @@ pub(super) fn project_configs(
let root_suppressed = ide_run::load_suppressed_config_ids(root)?;
let mut all = detected_and_saved(root, settings, &user, &root_suppressed);
if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? {
for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) {
for member in workspace.projects.into_iter().filter(|p| p.is_dir()) {
let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective();
let saved = ide_run::load_user_configs(&member)?;
let suppressed = ide_run::load_suppressed_config_ids(&member)?;
@@ -145,21 +142,20 @@ fn settings_for(
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
let owner = ide_run::scope::owner(&project.root, config);
let member = workspace.as_ref().and_then(|workspace| {
workspace
.projects
.iter()
.filter(|root| **root != project.root && owner.starts_with(root))
.max_by_key(|root| root.components().count())
.cloned()
});
if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() {
if workspace.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
@@ -203,66 +199,8 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
}
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
start_request(host, window, config, on_output, None).await
}
pub async fn start_request(
host: &Host,
window: &str,
config: RunConfig,
on_output: Emit<OutputChunk>,
request: Option<ide_core::RequestId>,
) -> IdeResult<RunInfo> {
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
let operation = config.extras.params.get("plugin.operation").cloned();
let result = start_inner(host, window, config, on_output, &cancel).await;
if let Some(id) = request {
host.end_request(id);
}
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
plugins.run_operation_started(window, &operation, info.id);
}
result
}
async fn start_inner(
host: &Host,
window: &str,
mut config: RunConfig,
on_output: Emit<OutputChunk>,
cancel: &ide_core::CancellationToken,
) -> IdeResult<RunInfo> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
host.require_trust(window)?;
let project = host.project(window)?;
if config.extras.target.is_none() {
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
}
if config.detected
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
&& let Some(plugins) = host.plugin_host()
{
let provider = plugins.run_detection();
let binary =
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
provider
.detect(&project.root)
.into_iter()
.find(|c| c.id == config.id)
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
.command
} else {
binary.to_string_lossy().into_owned()
};
config.detected = false;
}
let mut sink = project.sink.clone();
if let Some(plugins) = host.plugin_host() {
sink = plugins.run_operation_sink(window, &mut config, sink)?;
}
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
@@ -272,38 +210,6 @@ async fn start_inner(
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
let settings = project.settings.effective();
if id.starts_with("plugin-target:") {
let plugins =
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let root = project.root.clone();
let id = id.to_owned();
let token = cancel.clone();
let config = host
.blocking(move || {
let operation = if config.is_test {
ide_plugin::RunTargetOperation::Test
} else {
ide_plugin::RunTargetOperation::Run
};
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
})
.await?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&StartOptions {
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
ruby_reporter: None,
},
sink,
Box::new(move |c| on_output(c)),
);
}
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
@@ -321,12 +227,15 @@ async fn start_inner(
&project.root,
&config,
&options,
sink.clone(),
project.sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
return host
.remote
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
@@ -377,6 +286,7 @@ async fn start_inner(
ruby_reporter: ruby_reporter_dir(host, &config),
};
project.require_trust()?;
let mut sink = project.sink.clone();
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
let previous = sink;
let root = project.root.clone();
@@ -407,9 +317,6 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
if let Some(plugins) = host.plugin_host() {
plugins.run_operation_stopped(id);
}
let settings = match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
+6 -7
View File
@@ -218,13 +218,12 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.filter(|s| !s.is_empty())
.or_else(|| artifacts.first().map(|a| &a.id))
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
let mut plan = ide_build::web::build(
&project.root,
&model,
id,
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
)?;
plan.env.extend(config.env.clone());
let mut build = RunConfig {
id: format!("tomcat-build:{}", config.id),
+57 -66
View File
@@ -34,12 +34,67 @@ pub async fn start(
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
// ignored names are never searched.
query.exclude.extend(project.search_excludes());
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let cancel = host.begin_request(request);
let events = on_event.clone();
let documents = project.documents.clone();
let root = project.root.clone();
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
// The whole project also covers the Project Structure content roots.
let content_roots = match query.scope {
ide_search::SearchScope::Project
| ide_search::SearchScope::FileSet { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
_ => Vec::new(),
};
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let languages = project.languages.clone();
let syntax = host.syntax();
let events = on_event.clone();
let summary = host
.blocking(move || {
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
Some(ProjectFileSet::All) | None => true,
};
// Comments and string literals for the search context filter.
let literals = |path: &Path, text: &str| {
let language = languages.detect(path, text.lines().next());
let found = syntax.literals(&language, text)?;
Some(found.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
Some(&filter),
Some(&literals),
&|p| documents.search_text_of(p),
&cancel,
&|mut file| {
if dir != &root {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
file.rel_path = format!("{name}/{}", file.rel_path);
}
events(SearchEvent::File { file });
},
)?;
total.files += s.files;
total.matches += s.matches;
total.elapsed_ms += s.elapsed_ms;
total.limit_hit |= s.limit_hit;
total.cancelled |= s.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
})
.await;
host.end_request(request);
@@ -47,69 +102,5 @@ pub async fn start(
Ok(())
}
/// Shared Find in Files engine: project excludes are added by the caller.
pub(crate) fn search_project(
project: &crate::project::Project,
syntax: &ide_language::Syntax,
query: &SearchQuery,
cancel: &ide_core::CancellationToken,
emit: &(dyn Fn(FileMatches) + Sync),
) -> IdeResult<SearchSummary> {
let root = &project.root;
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root));
let include_root = !workspace
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
let content_roots = match query.scope {
ide_search::SearchScope::Project
| ide_search::SearchScope::FileSet { .. }
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
_ => Vec::new(),
};
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
_ => true,
};
let literals = |path: &Path, text: &str| {
let language = project.languages.detect(path, text.lines().next());
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
let mut remaining = query.clone();
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
remaining.max_matches = max.saturating_sub(total.matches).max(1);
let summary = ide_search::search_in_context(
dir,
&remaining,
Some(&filter),
Some(&literals),
&|p| project.documents.search_text_of(p),
cancel,
&|mut file| {
if dir != root {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
file.rel_path = format!("{name}/{}", file.rel_path);
}
emit(file);
},
)?;
total.files += summary.files;
total.matches += summary.matches;
total.elapsed_ms += summary.elapsed_ms;
total.limit_hit |= summary.limit_hit || total.matches >= max;
total.cancelled |= summary.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
}
mod commands;
pub use commands::*;
-2
View File
@@ -24,10 +24,8 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
let runtime = ide_sdk::apply::tool_install_environment(&root, &settings);
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_runtime(runtime.commands, runtime.env)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
+4 -17
View File
@@ -426,8 +426,7 @@ pub async fn vcs_log_details_batch(
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
r.log_details_batch_cancellable(&revisions, &cancel)
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
@@ -674,11 +673,10 @@ pub async fn vcs_rename_branch(
repo: Option<PathBuf>,
from: String,
to: String,
unset_upstream: bool,
) -> IdeResult<()> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to, unset_upstream))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
}
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
@@ -997,17 +995,7 @@ pub async fn vcs_rebase_with(
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || {
let mut remotes = project.with_log_repo_at(repo.as_deref(), |r| r.remotes())?;
for r in &mut remotes {
let resolved = ide_ssh::resolve_ssh_remote_url(&r.url);
if resolved != r.url {
r.resolved_url = Some(resolved);
}
}
Ok(remotes)
})
.await
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -1391,8 +1379,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
.await
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
File diff suppressed because it is too large. Load diff
-205
View File
@@ -1,205 +0,0 @@
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
use ide_core::CancellationToken;
use ide_plugin::HostError;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
pub(super) trait Service: Send + Sync {
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError>;
fn close(&self, plugin: &str, id: u64);
fn clear(&self, plugin: &str);
fn forget_window(&self, window: &str);
}
struct Entry {
window: String,
path: String,
listener: Mutex<Option<TcpListener>>,
cancel: CancellationToken,
}
#[derive(Default)]
pub(super) struct Loopbacks {
next: AtomicU64,
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
fn respond(stream: &mut TcpStream, ok: bool) {
let body = if ok { "You can close this tab." } else { "Not found." };
let status = if ok { "200 OK" } else { "404 Not Found" };
let _ = write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
body.len()
);
}
impl Loopbacks {
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
self.entries.lock().retain(|(p, _), entry| {
if !predicate(p, entry) {
return true;
}
entry.cancel.cancel();
entry.listener.lock().take();
false
});
}
}
impl Service for Loopbacks {
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
let path = path.trim_start_matches('/');
if path.len() > 1024
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|| path.split('/').any(|s| matches!(s, "." | ".."))
{
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
}
let mut entries = self.entries.lock();
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
}
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
listener.set_nonblocking(true).map_err(failed)?;
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
let id = self.next.fetch_add(1, Ordering::Relaxed);
entries.insert(
(plugin.into(), id),
Arc::new(Entry {
window: window.into(),
path: format!("/{path}"),
listener: Mutex::new(Some(listener)),
cancel: CancellationToken::new(),
}),
);
Ok((id, uri))
}
fn wait(
&self,
plugin: &str,
id: u64,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Option<String>, HostError> {
let entry = self
.entries
.lock()
.get(&(plugin.into(), id))
.cloned()
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
let listener =
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
let deadline = Instant::now() + timeout;
let result = (|| loop {
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
match listener.accept() {
Ok((mut stream, address)) => {
if !address.ip().is_loopback() {
continue;
}
stream.set_nonblocking(true).map_err(failed)?;
let mut head = Vec::new();
loop {
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if Instant::now() >= deadline {
return Ok(None);
}
let mut buf = [0; 1024];
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
head.extend_from_slice(&buf[..n]);
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5))
}
Err(e) => return Err(failed(e)),
}
}
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
let query = target.and_then(|target| {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
});
stream.set_nonblocking(false).map_err(failed)?;
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
respond(&mut stream, query.is_some());
if query.is_some() {
return Ok(query);
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(failed(e)),
}
if Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(5));
})();
self.close(plugin, id);
result
}
fn close(&self, plugin: &str, id: u64) {
let entry = self.entries.lock().remove(&(plugin.into(), id));
if let Some(entry) = entry {
entry.cancel.cancel();
entry.listener.lock().take();
}
}
fn clear(&self, plugin: &str) {
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
}
fn forget_window(&self, window: &str) {
self.remove_where(|_, entry| entry.window == window);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_shot_raw_query_limits_timeout_and_cleanup() {
let service = Arc::new(Loopbacks::default());
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
assert!(uri.starts_with("http://127.0.0.1:"));
let (other, _) = service.begin("a", "main", "").unwrap();
assert!(service.begin("a", "main", "third").is_err());
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
let worker = std::thread::spawn(move || {
let mut stream = TcpStream::connect(host).unwrap();
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
let mut answer = String::new();
stream.read_to_string(&mut answer).unwrap();
assert!(answer.contains("You can close this tab"));
});
assert_eq!(
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
Some("code=a%2Bb&state=x".into())
);
worker.join().unwrap();
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
let (id, _) = service.begin("a", "main", "callback").unwrap();
service.forget_window("main");
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
}
}
-225
View File
@@ -1,225 +0,0 @@
//! Database ownership stays on the project host; credentials never pass back to guests.
use super::api::PluginApi;
use super::{PluginHost, WindowRequest};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
use ide_db::{DataSource, DbDriver, DbProperty};
use ide_plugin::{HostError, PluginDataSource};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[derive(serde::Serialize, serde::Deserialize)]
struct OwnedProject {
root: PathBuf,
sources: std::collections::BTreeSet<String>,
}
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
if source.id.is_empty()
|| source.id.len() > 128
|| source.name.trim().is_empty()
|| source.name.len() > 256
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|| source.options.len() > 128
|| [&source.host, &source.database, &source.user, &source.url]
.into_iter()
.flatten()
.any(|v| v.len() > 16_384 || v.contains('\0'))
{
return Err(bad());
}
let driver = match source.driver.as_str() {
"sqlite" => DbDriver::Sqlite,
"postgres" => DbDriver::Postgres,
"mysql" => DbDriver::Mysql,
"sqlServer" => DbDriver::SqlServer,
"clickhouse" => DbDriver::Clickhouse,
"oracle" => DbDriver::Oracle,
_ => return Err(bad()),
};
let secret = |key: &str| {
let key = key.to_ascii_lowercase();
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
};
if source.options.iter().any(|(k, v)| {
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
}) {
return Err(bad());
}
let mut out = DataSource {
name: source.name,
driver,
host: source.host.unwrap_or_else(|| "localhost".into()),
port: source.port,
database: source.database.unwrap_or_default(),
user: source.user.unwrap_or_default(),
save_password: true,
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
..Default::default()
};
if driver == DbDriver::Sqlite {
out.file = Some(out.database.clone().into());
}
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
if url.contains(';') {
return Err(bad());
}
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
return Err(bad());
}
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
if let Some((_, query)) = url.split_once('?') {
for part in query.split('&') {
let key = part.split('=').next().unwrap_or_default();
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
return Err(bad());
}
}
}
out.url_only = true;
out.url = url;
out.resolved().map_err(|_| bad())?;
}
Ok(out)
}
impl PluginApi {
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
if !workspace.projects().iter().any(|r| r == root) {
return Err(HostError::NotFound("project is not open".into()));
}
if !workspace.trusted(root) {
return Err(HostError::Denied("project is not trusted".into()));
}
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
let host = workspace
.database_host()
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
Ok((host, db))
}
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
Err(e) => return Err(HostError::Failed(e.to_string())),
};
if !projects.iter().any(|p| p.root == root) {
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
}
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
if project.sources.insert(id.into()) {
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
}
Ok(())
}
pub(super) fn connect_database(
&self,
root: &Path,
client: &str,
id: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let (_, db) = self.database_host(root)?;
if self.shared.project_of(client).as_deref() != Some(root) {
return Err(HostError::Invalid("requires this project's invoking window".into()));
}
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
}
}
pub(super) fn changed(host: &crate::Host, root: &Path) {
for project in host.projects().into_iter().filter(|p| p.root == root) {
project.sink.emit(IdeEvent::DbSourcesChanged);
}
}
impl PluginHost {
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
};
let host = self
.shared
.workspace()
.and_then(|w| w.database_host())
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
for project in projects {
let root = project.root;
let removed = ide_db::plugin::remove_all(&root, plugin)?;
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
let ids: std::collections::BTreeSet<_> = project
.sources
.iter()
.map(|id| ide_db::plugin::source_id(plugin, id))
.chain(removed.into_iter().map(|source| source.id))
.collect();
for id in ids {
store.delete(&id);
store.delete(&ide_db::secrets::ssh_key(&id));
for project in host.projects().into_iter().filter(|p| p.root == root) {
host.runtime.block_on(project.db.forget_plugin_source(&id));
}
}
changed(&host, &root);
}
std::fs::remove_file(file)?;
Ok(())
}
}
pub(super) fn error(e: IdeError) -> HostError {
use ide_core::error::ErrorKind;
match e.kind {
ErrorKind::NotFound => HostError::NotFound(e.message),
ErrorKind::InvalidInput => HostError::Invalid(e.message),
ErrorKind::PermissionDenied => HostError::Denied(e.message),
_ => HostError::Failed(e.message),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> PluginDataSource {
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
}
#[test]
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
}
for url in [
"postgres://user:top-secret@host/db",
"postgres://host/db?password=top-secret",
"postgres://host/db?PASSWORD=top-secret",
"postgres://host/db?to%6ben=top-secret",
] {
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
assert!(!error.to_string().contains("top-secret"));
}
for key in ["password", "accessToken", "client_secret", "PWD"] {
assert!(
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
);
}
let source = settings(PluginDataSource {
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
options: vec![("application_name".into(), "Example".into())],
..source()
})
.unwrap();
assert_eq!(source.resolved().unwrap().host, "host");
}
}
+2 -25
View File
@@ -79,9 +79,7 @@ impl Diagnostics {
doc.cancel.cancel();
doc.cancel = CancellationToken::new();
let Some(host) = editor_plugin_host() else { return };
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
&& !host.providers.any(ProviderKind::Injections)
{
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
return;
}
// Register only once per project incarnation; no project is retained by the host.
@@ -115,7 +113,6 @@ impl Diagnostics {
snapshot.text = current.text;
drop(documents);
let injections = host.providers.injection_generations(&snapshot.language);
let generations = host.providers.diagnostic_generations(&snapshot.language);
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
@@ -127,11 +124,6 @@ impl Diagnostics {
if state.closed || cancel.is_cancelled() {
return;
}
if injections != host.providers.injection_generations(&snapshot.language)
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
{
return;
}
let matching: HashSet<_> = host
.diagnostic_plugins(Some(&root), &snapshot.language)
.into_iter()
@@ -232,13 +224,6 @@ pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
impl PluginHost {
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
// Clone before taking a project's state lock (edits register in the other order).
let injector = self.get(plugin).is_ok_and(|p| {
p.manifest().declarations("providers").iter().any(|d| {
d.get("kinds")
.and_then(|v| v.as_array())
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
})
});
let projects = self.diagnostic_projects.lock().clone();
for (state, sink) in projects {
if let Some(state) = state.upgrade() {
@@ -246,15 +231,7 @@ impl PluginHost {
// A completed answer can still be waiting for another provider.
*state.generations.entry(plugin.to_owned()).or_default() += 1;
for doc in state.documents.values_mut() {
if injector {
for source in doc.sources.drain() {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{source}"),
items: vec![],
});
}
} else if doc.sources.remove(plugin) {
if doc.sources.remove(plugin) {
sink.emit(IdeEvent::Diagnostics {
path: doc.snapshot.path.clone().into(),
source: format!("plugin-provider:{plugin}"),
+1 -1
View File
@@ -281,7 +281,7 @@ mod tests {
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let root = tmp.path().canonicalize().unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
-568
View File
@@ -1,568 +0,0 @@
//! Window-owned plugin operations. Instance reclamation does not end them.
use super::api::{PluginApi, PromptEntry, Waiter};
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
pub(super) struct Operation {
pub plugin: String,
pub client: String,
pub kind: OperationKind,
pub root: Option<std::path::PathBuf>,
pub started: bool,
pub closed: bool,
pub run: Option<ide_core::RunId>,
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
}
pub(super) enum OperationKind {
Run { config: String, configuration: String, program: String },
Terminal { key: String, command: ide_plugin::ProcessCommand },
}
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
}
Ok(())
}
fn body(value: &str) -> Result<(), HostError> {
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
return Err(HostError::Invalid("content too large or contains NUL".into()));
}
Ok(())
}
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
label(&tab.key, 128)?;
label(&tab.title, 256)?;
body(&tab.content)?;
if let Some(name) = &tab.file_name {
label(name, 256)?;
if name.contains(['/', '\\']) {
return Err(HostError::Invalid("file-name cannot contain separators".into()));
}
}
if let Some(language) = &tab.language {
label(language, 64)?;
}
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
}
let mut ids = std::collections::BTreeSet::new();
for action in &tab.actions {
label(&action.id, 64)?;
label(&action.label, 128)?;
if !ids.insert(&action.id) {
return Err(HostError::Invalid("duplicate action id".into()));
}
if let Some(icon) = &action.icon {
label(icon, 128)?;
}
}
Ok(())
}
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
if let Some(content) = &reply.content {
body(content)?;
}
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
return Err(HostError::Invalid("content status or error too long".into()));
}
if let Some(diff) = &reply.diff {
body(&diff.left)?;
body(&diff.right)?;
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
label(value, 128)?;
}
if let Some(title) = &diff.title {
label(title, 256)?;
}
}
Ok(())
}
impl PluginApi {
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
let closed = self.shared.closed_clients.lock();
let blocked = self.shared.prompts_blocked.lock();
if closed.contains(client) || blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
let mut operations = self.shared.operations.lock();
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
return Err(HostError::Invalid("at most 32 active operations".into()));
}
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
operations.insert(
id.clone(),
Operation {
plugin: self.pkg.id().into(),
client: client.into(),
kind,
root: self.shared.project_of(client),
started: false,
closed: false,
terminal: None,
run: None,
},
);
Ok(id)
}
pub(super) fn window_request(
&self,
client: &str,
request: WindowRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<Option<String>, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
let blocked = self.shared.prompts_blocked.lock();
if closed.contains(client) || blocked.contains(self.pkg.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
);
}
self.shared.sink.emit(
Some(client),
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
);
let deadline = Instant::now() + timeout;
loop {
match rx.recv_timeout(Duration::from_millis(20)) {
Ok(CommandAnswer::Value(value)) => return Ok(value),
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(_) => {
self.shared.prompts.lock().remove(&id);
return Err(HostError::Cancelled);
}
}
}
}
}
impl PluginHost {
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
let operation = {
let mut operations = self.shared.operations.lock();
if !operations.get(id).is_some_and(|op| op.client == client) {
return false;
}
operations.remove(id).unwrap()
};
let event = match operation.kind {
OperationKind::Run { config, .. } => {
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
}
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
id: id.into(),
key,
status,
closed: stopped,
}),
};
self.post_owned_event(&operation.plugin, client, event);
true
}
/// Content actions bypass subscriptions, just like completions of owned operations.
pub fn content_tab_action(
&self,
client: &str,
plugin: &str,
action: ide_plugin::ContentTabAction,
) -> IdeResult<()> {
let tabs = self.shared.content_tabs.lock();
let tab = tabs
.get(&(plugin.into(), client.into(), action.key.clone()))
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
if !tab.actions.iter().any(|a| a.id == action.action) {
return Err(IdeError::invalid("unknown content action"));
}
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
drop(tabs);
let mut pending = self.shared.content_requests.lock();
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
return Err(IdeError::invalid("too many pending content actions"));
}
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
drop(pending);
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
Ok(())
}
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
if self.shared.client_closed(client) {
return;
}
self.queues
.lock()
.entry((plugin.into(), Some(client.into())))
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
.events
.push(event);
}
}
/// Dynamic configurations opt in with marker files, just like static detectors.
pub(super) fn configurations_declaration(
pkg: &ide_extension::Package,
value: toml::Value,
) -> Result<ide_extension::UiDecl, String> {
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
return Err("runConfigurations needs code and 1-16 marker files".into());
}
for file in &decl.files {
ide_extension::check_relative(std::path::Path::new(file), "files")?;
}
Ok(ide_extension::UiDecl::new(
serde_json::to_string(&decl.files).unwrap(),
"Run configurations",
serde_json::to_value(decl).unwrap(),
))
}
impl PluginHost {
/// Refreshes configuration exports on the same listing path as static detectors.
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return;
}
let owners: std::collections::BTreeSet<_> = self
.ui
.items("runConfigurations", None)
.into_iter()
.filter(|d| {
d.decl["files"]
.as_array()
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
})
.map(|d| d.plugin_id)
.collect();
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
for plugin in owners {
let project = root.to_path_buf();
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
rt.run_configurations(
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
Duration::from_secs(2),
)
});
let Ok(items) = result else { continue };
if let Err(error) = validate_configurations(root, &items) {
tracing::warn!(%plugin, %error, "invalid run configurations");
continue;
}
self.shared.run_configurations.write().insert((plugin, project), items);
}
}
/// Resolves only an owned plugin test configuration in the client's trusted project.
fn test_configuration(
&self,
client: &str,
config: &str,
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
return Err(IdeError::invalid("project is not trusted"));
}
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
let configuration = self
.shared
.workspace()
.ok_or_else(IdeError::cancelled)?
.run_configuration(client, owner, id)
.map_err(|e| IdeError::invalid(e.to_string()))?;
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
{
return Ok(None);
}
Ok(Some((root, owner.to_owned(), id.to_owned())))
}
/// Derives replacement argv for a user action, under the command budget.
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
if tests.is_empty()
|| tests.len() > 10000
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
{
return Ok(None);
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
let timeout = self.limits.timeout;
let started = Instant::now();
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
}
/// Parses a bounded batch under the editor-provider soft/hard budgets.
pub fn parse_test_results(
&self,
client: &str,
config: &str,
chunk: String,
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
if chunk.len() > 64 << 10 {
return Err(IdeError::invalid("test output batch too large"));
}
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
let plugin = self.get(&owner)?;
let (actor, activated) = self.ensure_active(&plugin, None)?;
if activated {
self.changed();
}
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
let started = Instant::now();
let result =
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
if let Some(events) = &mut events {
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
return Ok(None);
}
for event in events {
let location = match event {
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
v.location.as_mut()
}
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
_ => None,
};
if let Some(location) = location {
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
return Ok(None);
};
if location.range.end < location.range.start {
return Ok(None);
}
location.path = path;
}
}
}
Ok(events)
}
}
fn validate_configurations(
root: &std::path::Path,
items: &[ide_plugin::PluginRunConfiguration],
) -> Result<(), HostError> {
let mut ids = std::collections::BTreeSet::new();
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
return Err(HostError::Invalid("too many run configurations".into()));
}
for c in items {
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
label(&c.name, 128)?;
if !ids.insert(&c.id)
|| c.program.is_empty()
|| c.program.len() > 256
|| c.program.contains(char::is_whitespace)
|| c.args.len() > 256
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
{
return Err(HostError::Invalid("invalid run configuration".into()));
}
if let Some(cwd) = &c.cwd {
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
}
}
Ok(())
}
impl PluginHost {
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
pub fn spawn_terminal_session(
self: &std::sync::Arc<Self>,
host: &std::sync::Arc<crate::Host>,
client: &str,
operation: &str,
cols: u16,
rows: u16,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let (plugin, command) = {
let mut operations = self.shared.operations.lock();
let op = operations
.get_mut(operation)
.filter(|op| op.client == client)
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
if op.started || op.closed || op.root != self.shared.project_of(client) {
return Err(IdeError::invalid("terminal operation already started or project changed"));
}
let OperationKind::Terminal { command, .. } = &op.kind else {
return Err(IdeError::invalid("not a terminal operation"));
};
op.started = true;
(op.plugin.clone(), command.clone())
};
let options = super::web::WebPtyCommand {
program: command.program,
args: command.args,
env: command.env.into_iter().collect(),
cols,
rows,
};
let weak = Arc::downgrade(self);
let (id, window) = (operation.to_owned(), client.to_owned());
let completion = Arc::new(move |status| {
if let Some(plugins) = weak.upgrade() {
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
plugins.finish_operation(&window, &id, status, closed);
}
});
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
self.pty_options(&plugin, client, options)
});
if let Ok(info) = &result {
let mut operations = self.shared.operations.lock();
if let Some(op) = operations.get_mut(operation) {
op.terminal = Some((Arc::downgrade(host), info.id));
if op.closed {
let _ = host.terminals.kill(info.id);
}
}
}
if result.is_err()
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
{
op.started = false;
}
result
}
}
impl PluginHost {
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
let terminal = {
let mut operations = self.shared.operations.lock();
let Some(op) = operations
.get_mut(id)
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
else {
return Ok(());
};
op.closed = true;
op.terminal.clone()
};
if let Some((host, id)) = terminal
&& let Some(host) = host.upgrade()
{
host.terminals.kill(id)?;
}
Ok(())
}
}
impl PluginHost {
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':'))
else {
return Err(IdeError::invalid("not a plugin configuration"));
};
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
if owned.debug_adapter.is_none() {
return Err(IdeError::invalid("this configuration has no debugger"));
}
if config.extras.target.is_none() {
config.extras.target = owned.extras.target.clone();
}
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("debugging this target is not supported"));
}
config.command = self
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
.to_string_lossy()
.into_owned();
config.debug_adapter = owned.debug_adapter;
Ok(config)
}
}
/// Internal launch metadata; it is ignored after the original operation finishes.
pub(super) const RUN_OPERATION: &str = "plugin.operation";
impl PluginHost {
pub(crate) fn run_operation_sink(
self: &Arc<Self>,
client: &str,
config: &mut ide_run::RunConfig,
previous: Arc<dyn ide_core::EventSink>,
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
if op.client != client {
return None;
}
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
});
let Some((plugin, root, program)) = origin else { return Ok(previous) };
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
program
} else {
binary.to_string_lossy().into_owned()
};
config.detected = false;
let (weak, client) = (Arc::downgrade(self), client.to_owned());
Ok(Arc::new(move |event: ide_core::IdeEvent| {
if let ide_core::IdeEvent::RunExited { code, .. } = &event
&& let Some(plugins) = weak.upgrade()
{
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
plugins.finish_operation(&client, &id, *code, stopped);
}
previous.emit(event);
}))
}
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
if let Some(op) = self
.shared
.operations
.lock()
.get_mut(operation)
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
{
op.run = Some(run);
}
}
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
op.closed = true;
}
}
}
-1
View File
@@ -113,7 +113,6 @@ pub async fn plugin_navigation_index(
&project.root,
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
Invocation {
command_chain: Vec::new(),
client: Some(client.clone()),
workspace_root: Some(project.root.clone()),
editor: None,
-158
View File
@@ -1,158 +0,0 @@
//! Embedded documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
use std::sync::Arc;
/// Allowed output ranges for one virtual document; the host document is unrestricted.
pub(super) struct Bounds(Option<Vec<Range>>);
impl Bounds {
pub fn unrestricted() -> Self {
Self(None)
}
pub fn contains(&self, range: Range) -> bool {
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
}
pub fn lines(&self, start: u32, end: u32) -> bool {
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
}
}
impl PluginHost {
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
if !self.providers.any(ProviderKind::Injections) {
return vec![];
}
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
return Vec::new();
}
let text = ide_text::Document::new(&doc.text);
let d = Arc::new(doc.clone());
let mut items: Vec<_> = self
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
.into_iter()
.flat_map(|(_, v)| v)
.filter(|i| {
!i.range.is_empty()
&& super::language_features::valid_range_in(&text, i.range)
&& i.language != doc.language
&& !i.language.is_empty()
&& i.language.len() <= 64
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
})
.collect();
items.sort_by_key(|i| i.range);
let mut end = Pos::default();
items.retain(|i| {
if i.range.start < end {
return false;
}
end = i.range.end;
true
});
items.truncate(1000);
items
}
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
let injections = self.provide_injections(doc);
let mut languages = Vec::new();
for i in &injections {
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
continue;
}
languages.push(i.language.clone());
if languages.len() == 32 {
break;
}
}
let mut docs = Vec::new();
for language in languages {
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
let text = virtual_text(doc, &injections, &language);
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
}
docs.push((doc.clone(), Bounds(None)));
docs
}
}
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
let mut index = 0;
let mut at = Pos::default();
let mut text = String::with_capacity(doc.text.len());
for c in doc.text.chars() {
while index < ranges.len() && ranges[index].end <= at {
index += 1;
}
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
if inside || c == '\n' || c == '\r' {
text.push(c);
} else {
for _ in 0..c.len_utf16() {
text.push(' ');
}
}
if c == '\n' {
at.line += 1;
at.col = 0;
} else {
at.col += c.len_utf16() as u32;
}
}
text
}
/// Shared by local and remote projects; all plugin work runs on the host.
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
parking_lot::Mutex::new(Vec::new());
pub(super) fn providers_changed() {
let projects = {
let mut projects = LSP_PROJECTS.lock();
projects.retain(|p| p.strong_count() > 0);
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
};
for project in projects {
project.injection_providers_changed();
}
}
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
plugins
.provide_injections(&ProviderDocument {
path: path.to_path_buf(),
language: language.0.clone(),
version: version.into(),
text: text.to_owned(),
})
.into_iter()
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
.collect()
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn virtual_documents_preserve_utf16_columns_and_newlines() {
let d = ProviderDocument {
path: "/p/a".into(),
language: "host".into(),
version: 7,
text: "😀 sql\n outside\n sql".into(),
};
let ranges = vec![
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
];
let text = virtual_text(&d, &ranges, "sql");
assert_eq!(text, " sql\n \n sql");
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
}
}
+8 -195
View File
@@ -78,25 +78,6 @@ pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
/// the themes their IDEs are set to first.
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
find_installed(&latest_product_versions(local_ide_dirs()))
}
/// The settings folder of a JetBrains IDE (or Android Studio) of this user.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstalledIdeSettings {
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
pub ide: String,
/// What Import Settings reads.
pub path: IpcPath,
}
/// The settings folders of the JetBrains IDEs of this user, most recently used first.
pub fn installed_ide_settings() -> Vec<InstalledIdeSettings> {
settings_folders(&local_ide_dirs())
}
fn local_ide_dirs() -> Vec<IdeDir> {
let Some(home) = dirs::home_dir() else { return Vec::new() };
let layout = if cfg!(target_os = "macos") {
Layout::Mac
@@ -105,65 +86,7 @@ fn local_ide_dirs() -> Vec<IdeDir> {
} else {
Layout::Xdg
};
ide_dirs(layout, &home, |key| std::env::var_os(key))
}
fn settings_folders(ides: &[IdeDir]) -> Vec<InstalledIdeSettings> {
let mut used: Vec<_> =
ides.iter().filter_map(|ide| settings_modified(&ide.config).map(|time| (ide, time))).collect();
used.sort_by(|(a, at), (b, bt)| {
bt.cmp(at).then_with(|| a.label.cmp(&b.label)).then_with(|| a.config.cmp(&b.config))
});
used.into_iter()
.map(|(ide, _)| InstalledIdeSettings { ide: ide.label.clone(), path: ide.config.clone().into() })
.collect()
}
fn settings_modified(config: &Path) -> Option<std::time::SystemTime> {
let options = config.join("options");
// Match the folder reader: never count links or walk below the settings directories.
if !config.symlink_metadata().ok()?.is_dir() || !options.symlink_metadata().ok()?.is_dir() {
return None;
}
let mut modified = None;
let mut customised = false;
for entry in std::fs::read_dir(&options).ok()?.flatten() {
let path = entry.path();
if !entry.file_type().is_ok_and(|t| t.is_file())
|| !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml"))
{
continue;
}
let Ok(meta) = entry.metadata() else { continue };
modified = modified.max(meta.modified().ok());
// Startup bookkeeping can exist before the user has changed any settings.
let name = entry.file_name();
customised |= meta.len() > 0
&& !matches!(
name.to_str(),
Some(
"other.xml"
| "nonRoamable.xml"
| "early-access-registry.xml"
| "applicationInfo.xml"
| "updates.xml"
| "usage.statistics.xml"
)
);
}
customised |= ["keymaps", "codestyles", "colors", "templates", "inspection"].iter().any(|sub| {
let dir = config.join(sub);
dir.symlink_metadata().is_ok_and(|m| m.is_dir())
&& std::fs::read_dir(dir).into_iter().flatten().flatten().any(|entry| {
entry.file_type().is_ok_and(|t| t.is_file())
&& entry.metadata().is_ok_and(|m| m.len() > 0)
&& entry
.path()
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xml") || e.eq_ignore_ascii_case("icls"))
})
});
customised.then(|| modified.or_else(|| options.metadata().ok()?.modified().ok()).unwrap_or(std::time::UNIX_EPOCH))
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
}
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
@@ -499,7 +422,8 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest version first.
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -539,14 +463,9 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
out
}
/// Theme discovery only uses the newest version of each product.
fn latest_product_versions(mut ides: Vec<IdeDir>) -> Vec<IdeDir> {
let mut products = std::collections::HashSet::new();
ides.retain(|ide| products.insert(ide.product.clone()));
ides
out.retain(|ide| products.insert(ide.product.clone()));
out
}
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
@@ -868,16 +787,15 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// Settings import keeps every version; theme discovery uses only the newest.
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 3);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
assert_eq!(latest_product_versions(dirs).len(), 1);
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 2);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
@@ -963,109 +881,4 @@ mod tests {
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
assert!(find_installed(&[]).is_empty());
}
#[test]
fn unused_newer_versions_do_not_hide_settings() {
let tmp = tempfile::tempdir().unwrap();
for (layout, root) in [
(Layout::Mac, "Library/Application Support"),
(Layout::Windows, "AppData/Roaming"),
(Layout::Xdg, ".config"),
] {
let root = tmp.path().join(root).join("JetBrains");
let used = root.join("IntelliJIdea2024.3");
let fresh = root.join("IntelliJIdea2025.2");
write(&used.join("options/editor.xml"), b"<application><component name=\"EditorSettings\"/></application>");
std::fs::create_dir_all(&fresh).unwrap();
let expected = vec![InstalledIdeSettings { ide: "IntelliJ IDEA 2024.3".into(), path: used.into() }];
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
write(&fresh.join("options/other.xml"), b"<application/>");
write(&fresh.join("options/early-access-registry.xml"), b"<application/>");
assert_eq!(settings_folders(&ide_dirs(layout, tmp.path(), |_| None)), expected);
}
}
#[test]
fn settings_candidates_keep_used_versions_and_sort_by_options_modification() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("Library/Application Support/JetBrains");
let put = |product: &str, file: &str, seconds| {
let path = root.join(product).join(file);
write(&path, b"<application/>");
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
.unwrap();
};
put("IntelliJIdea2025.2", "options/editor.xml", 100);
put("IntelliJIdea2024.3", "options/editor.xml", 200);
put("IntelliJIdea2024.3", "options/editor-font.xml", 400);
put("PyCharm2025.1", "options/editor.xml", 300);
// Directory times and unrelated files do not change options-file ordering.
write(&root.join("IntelliJIdea2025.2/options/README.txt"), b"recent");
let dirs = ide_dirs(Layout::Mac, tmp.path(), |_| None);
let found = settings_folders(&dirs);
assert_eq!(
found.iter().map(|s| s.ide.as_str()).collect::<Vec<_>>(),
["IntelliJ IDEA 2024.3", "PyCharm 2025.1", "IntelliJ IDEA 2025.2"]
);
assert_eq!(found[0].path, root.join("IntelliJIdea2024.3"));
assert_eq!(latest_product_versions(dirs).len(), 2);
}
#[test]
fn skips_empty_and_startup_only_settings_but_keeps_custom_schemes() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path();
assert!(settings_modified(config).is_none());
std::fs::create_dir(config.join("options")).unwrap();
assert!(settings_modified(config).is_none());
for file in ["other", "nonRoamable", "early-access-registry", "applicationInfo", "updates", "usage.statistics"]
{
write(
&config.join(format!("options/{file}.xml")),
b"<application><component name=\"Startup\"/></application>",
);
}
write(&config.join("options/editor.xml"), b"");
write(&config.join("options/nested/editor.xml"), b"<application/>");
for sub in ["keymaps", "codestyles", "colors", "templates", "inspection"] {
std::fs::create_dir(config.join(sub)).unwrap();
write(&config.join(sub).join("README.txt"), b"not settings");
}
assert!(settings_modified(config).is_none());
for (sub, file) in [
("keymaps", "custom.xml"),
("codestyles", "custom.xml"),
("colors", "custom.icls"),
("templates", "custom.xml"),
("inspection", "custom.xml"),
] {
let path = config.join(sub).join(file);
write(&path, b"<custom/>");
assert!(settings_modified(config).is_some(), "{sub} is a user setting");
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn lists_the_settings_folders_of_ides_that_have_been_run() {
let tmp = tempfile::tempdir().unwrap();
let ide = |label: &str, product: &str, version: (u32, u32)| {
let config = tmp.path().join(format!("{product}{}.{}", version.0, version.1));
IdeDir { label: label.into(), product: product.into(), version, config: config.clone(), plugins: config }
};
let used = ide("IntelliJ IDEA 2025.2", "IntelliJIdea", (2025, 2));
let fresh = ide("PyCharm 2025.1", "PyCharm", (2025, 1));
let used_config = used.config.clone();
write(&used_config.join("options/editor.xml"), b"<application/>");
std::fs::create_dir_all(&fresh.config).unwrap();
assert_eq!(
settings_folders(&[used, fresh]),
[InstalledIdeSettings { ide: "IntelliJ IDEA 2025.2".into(), path: used_config.into() }]
);
assert!(settings_folders(&[]).is_empty());
}
}
-174
View File
@@ -1,174 +0,0 @@
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
use super::api::net_runtime;
use ide_core::CancellationToken;
use ide_plugin::HostError;
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
struct Connection {
stream: tokio::sync::Mutex<Box<dyn Stream>>,
closed: CancellationToken,
}
impl Drop for Connection {
fn drop(&mut self) {
self.closed.cancel();
}
}
#[derive(Default)]
pub(super) struct Connections {
next: AtomicU64,
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
impl Connections {
pub fn connect(
&self,
plugin: &str,
name: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<u64, HostError> {
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
} else {
Ok(())
}
};
check(&self.entries.lock())?;
let stream: Box<dyn Stream> = net_runtime().block_on(async {
let connect = async {
#[cfg(unix)]
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
#[cfg(windows)]
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
#[cfg(not(any(unix, windows)))]
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
};
tokio::select! {
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
() = cancel.cancelled() => Err(HostError::Cancelled),
}
})?;
let mut entries = self.entries.lock();
check(&entries)?;
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let id = self.next.fetch_add(1, Ordering::Relaxed);
entries.insert(
(plugin.into(), id),
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
);
Ok(id)
}
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
self.entries
.lock()
.get(&(plugin.into(), id))
.cloned()
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
}
pub fn write(
&self,
plugin: &str,
id: u64,
bytes: Vec<u8>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let entry = self.get(plugin, id)?;
net_runtime().block_on(async {
tokio::select! {
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
() = cancel.cancelled() => Err(HostError::Cancelled),
() = entry.closed.cancelled() => Err(HostError::Cancelled),
}
})
}
pub fn read(
&self,
plugin: &str,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<Vec<u8>, HostError> {
let entry = self.get(plugin, id)?;
net_runtime().block_on(async {
let read = async {
let mut bytes = vec![0; max.min(1 << 20)];
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
bytes.truncate(count);
Ok(bytes)
};
tokio::select! {
biased;
() = cancel.cancelled() => Err(HostError::Cancelled),
() = entry.closed.cancelled() => Err(HostError::Cancelled),
r = read => r,
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
}
})
}
pub fn close(&self, plugin: &str, id: u64) {
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
entry.closed.cancel();
}
}
pub fn clear(&self, plugin: &str) {
self.entries.lock().retain(|(p, _), entry| {
if p != plugin {
return true;
}
entry.closed.cancel();
false
});
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
#[test]
fn echo_limit_and_shutdown() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("echo.sock");
let listener = UnixListener::bind(&path).unwrap();
let worker = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = [0; 4];
stream.read_exact(&mut bytes).unwrap();
stream.write_all(&bytes).unwrap();
// Accept the remaining seven connections before shutdown.
for _ in 1..8 {
listener.accept().unwrap();
}
});
let pool = Connections::default();
let token = CancellationToken::new();
let timeout = Duration::from_secs(2);
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
for _ in 1..8 {
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
}
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
assert!(pool.read("b", id, 4, timeout, &token).is_err());
pool.clear("a");
assert!(pool.read("a", id, 4, timeout, &token).is_err());
worker.join().unwrap();
}
}
@@ -1,462 +0,0 @@
//! API 1.4 language features. Results are bounded and checked against the snapshot.
use super::{PluginHost, ProviderKind};
use ide_core::{IdeResult, Pos, Range};
use ide_plugin::{ProviderDocument, language_features::*};
use std::sync::Arc;
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
valid_range_in(&ide_text::Document::new(&doc.text), range)
}
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
if range.start > range.end {
return false;
}
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
}
impl PluginHost {
pub fn language_format(
&self,
id: &str,
doc: &ProviderDocument,
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<ide_core::TextEdit>> {
let candidates = if let Some(r) = range {
self.feature_documents(doc, Some(r.start))
} else {
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
};
let (target, bounds) = candidates
.into_iter()
.find(|(d, b)| {
range.is_none_or(|r| b.contains(r))
&& self
.provider_plugins(ProviderKind::Formatting, &d.language)
.iter()
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
})
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
let text = ide_text::Document::new(&target.text);
let d = Arc::new(target);
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
})?;
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
}
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
ranges.sort();
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
}
Ok(edits)
}
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
rt.language_signature_help(&d, at, t)
});
for (_, v) in results {
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
return Some(v);
}
}
}
None
}
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
rt.language_document_highlights(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
out.push(i);
}
}
}
}
out
}
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
out.push(i);
}
}
}
}
out
}
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results =
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
for (p, items) in results {
let commands: std::collections::HashSet<_> =
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
for i in items {
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
rt.language_selection_ranges(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
out.push(i);
}
}
}
}
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
out.dedup();
let mut end = None;
out.retain(|r| {
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
if ok {
end = Some(*r);
}
ok
});
out
}
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let lines = d.text.split('\n').count() as u32;
let snapshot = d.clone();
let d = Arc::new(d);
let results =
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
for (p, items) in results {
for i in items {
let _ = &p;
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
out.push(i);
}
}
}
}
out
}
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
for (p, items) in results {
for i in items {
if valid_range_in(&text, i.range)
&& bounds.contains(i.range)
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
{
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
if !bounds.contains(color.range)
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
{
continue;
}
let snapshot = d.clone();
let d = Arc::new(d);
let color = color.clone();
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
rt.language_color_presentations(&d, color.clone(), t)
});
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
return items.into_iter().take(100).collect();
}
}
Vec::new()
}
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let snapshot = d.clone();
let text = ide_text::Document::new(&snapshot.text);
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
rt.language_document_symbols(&d, t)
});
for (_, items) in results {
let mut remap = std::collections::HashMap::new();
for (index, mut i) in items.into_iter().enumerate() {
if !valid_range_in(&text, i.range)
|| !valid_range_in(&text, i.selection_range)
|| i.selection_range.start < i.range.start
|| i.selection_range.end > i.range.end
|| !bounds.contains(i.range)
{
continue;
}
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
remap.insert(index, out.len() as u32);
out.push(i);
}
}
}
out
}
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
let mut out = Vec::new();
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
rt.language_implementation(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
out.push(i);
}
}
}
out
}
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
let mut out = Vec::new();
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
let snapshot = d.clone();
let d = Arc::new(d);
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
rt.language_type_definition(&d, at, t)
});
for (p, items) in results {
for i in items {
let _ = &p;
out.push(i);
}
}
}
out
}
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
let mut out = Vec::new();
for (d, bounds) in self.feature_documents(doc, None) {
let lines = d.text.split('\n').count() as u32;
let snapshot = d.clone();
let d = Arc::new(d);
let results = self
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
for (p, items) in results {
let commands: std::collections::HashSet<_> =
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
for i in items {
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
out.push((p.id().to_owned(), i));
}
}
}
}
out
}
}
impl PluginHost {
pub(crate) fn run_language_command(
&self,
plugin: &str,
command: &str,
argument: Option<&str>,
invocation: ide_plugin::Invocation,
) -> IdeResult<()> {
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
}
self.run_command(plugin, command, argument, invocation).map(|_| ())
}
}
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
let signatures: Vec<_> = h
.signatures
.into_iter()
.take(100)
.map(|s| {
let len = s.label.encode_utf16().count() as u32;
let parameters = s
.parameters
.into_iter()
.take(100)
.map(|p| match p.label {
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
ParameterLabel::Text(t) => s
.label
.find(&t)
.map(|a| {
let start = s.label[..a].encode_utf16().count() as u32;
(start, start + t.encode_utf16().count() as u32)
})
.unwrap_or((0, 0)),
})
.collect();
ide_lsp::types::Signature {
label: s.label,
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
parameters,
}
})
.collect();
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
let active_parameter =
h.active_parameter
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
as u32);
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
}
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
// A parent must precede its child, ruling out cycles and unbounded recursion.
let mut depths = vec![0usize; items.len()];
let mut parents = vec![None; items.len()];
for (idx, item) in items.iter().enumerate() {
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
*p < idx
&& depths[*p] < 63
&& items[*p].range.start <= item.range.start
&& item.range.end <= items[*p].range.end
}) {
parents[idx] = Some(parent);
depths[idx] = depths[parent] + 1;
}
}
let mut nodes: Vec<_> = items
.into_iter()
.map(|i| {
Some(ide_core::OutlineItem {
name: i.name,
detail: Some(i.detail).filter(|s| !s.is_empty()),
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
.unwrap_or(ide_core::SymbolKind::Unknown),
range: i.range,
selection_range: i.selection_range,
children: vec![],
visibility: None,
})
})
.collect();
let mut roots = Vec::new();
for idx in (0..nodes.len()).rev() {
let mut node = nodes[idx].take().expect("once");
node.children.reverse();
if let Some(parent) = parents[idx] {
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
} else {
roots.push(node);
}
}
roots.reverse();
roots
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct FormatterChoice {
pub id: String,
pub name: String,
pub languages: Vec<String>,
}
impl PluginHost {
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
for (id, languages) in self.providers.formatter_declarations() {
let Ok(p) = self.get(&id) else {
continue;
};
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
continue;
}
out.entry(id.clone())
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
.languages
.extend(languages);
}
out.into_values()
.map(|mut c| {
c.languages.sort();
c.languages.dedup();
c
})
.collect()
}
}
fn symbol_kind_name(kind: &str) -> String {
let mut parts = kind.split('-');
let mut out = parts.next().unwrap_or_default().to_owned();
for part in parts {
let mut chars = part.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
out.extend(chars);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outline_uses_parent_order_containment_and_string_kinds() {
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
let item = |name: &str, parent| DocumentSymbol {
name: name.into(),
detail: String::new(),
kind: "enum-member".into(),
range,
selection_range: range,
parent,
};
let out =
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
assert_eq!(out[1].name, "future");
assert_eq!(out[1].children[0].name, "other");
}
}
-176
View File
@@ -1,176 +0,0 @@
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
use super::{PluginHost, PluginStatus};
use ide_core::{IdeError, IdeResult};
use ide_extension::{Package, UiDecl, decode};
use ide_plugin::Invocation;
use serde::Deserialize;
pub(super) const POINT: &str = "mcpTools";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Declaration {
name: String,
description: String,
input_schema: std::path::PathBuf,
read_only: bool,
destructive: bool,
}
fn tool_name(plugin: &str, name: &str) -> String {
let plugin: String = plugin
.chars()
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
.collect();
format!("{plugin}__{name}")
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
if pkg.manifest.wasm.is_none() {
return Err("mcpTools requires wasm".into());
}
let d: Declaration = decode(POINT, value)?;
if d.name.is_empty()
|| d.name.len() > 40
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|| tool_name(pkg.id(), &d.name).len() > 64
|| d.description.trim().is_empty()
|| d.description.len() > 4096
{
return Err(
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
.into(),
);
}
let text = pkg.read_text(&d.input_schema)?;
if text.len() > 256 << 10 {
return Err("mcpTools: schema exceeds 256 KiB".into());
}
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return Err("mcpTools: inputSchema must describe an object".into());
}
// No HTTP or filesystem resolver features: schemas must be self-contained.
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
let mut out = UiDecl::new(
d.name.clone(),
d.description.clone(),
serde_json::json!({
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
}),
);
out.files.push(d.input_schema);
Ok(out)
}
impl PluginHost {
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
let mut tools = vec![];
for item in self.ui.items(POINT, None) {
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
{
continue;
}
let d = &item.decl;
let Some(name) = d["name"].as_str() else { continue };
tools.push((
plugin.id().to_owned(),
name.to_owned(),
ide_mcp::Tool {
name: tool_name(plugin.id(), name),
description: format!(
"{} (plugin: {} [{}])",
d["description"].as_str().unwrap_or_default(),
plugin.manifest().name,
plugin.id()
),
input_schema: d["inputSchema"].clone(),
output_schema: None,
read_only: d["readOnly"].as_bool().unwrap_or(false),
destructive: d["destructive"].as_bool().unwrap_or(true),
},
));
}
let mut counts = std::collections::HashMap::new();
for (_, _, tool) in &tools {
*counts.entry(tool.name.clone()).or_insert(0) += 1;
}
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
tools
}
pub fn call_mcp_tool(
&self,
plugin: &str,
tool: &str,
arguments: &str,
invocation: Invocation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
if arguments.len() > 1 << 20 {
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
}
let root =
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
let (_, _, declared) = self
.mcp_tools(std::path::Path::new(root))
.into_iter()
.find(|(p, n, _)| p == plugin && n == tool)
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
let validator =
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
if !validator.is_valid(&args) {
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
}
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
rt.mcp_call(&tool, &arguments, inv)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
let tmp = tempfile::tempdir().unwrap();
let pkg = |api: &str| {
Package::new(
ide_extension::Manifest::parse(&format!(
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
))
.unwrap(),
tmp.path().into(),
)
};
let decl = |name: &str, path: &str| {
toml::from_str::<toml::Value>(&format!(
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
))
.unwrap()
};
std::fs::write(
tmp.path().join("args.json"),
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
)
.unwrap();
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
}
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
for schema in [
r#"{"type":"nonsense"}"#,
r#"{"type":"object","required":42}"#,
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
] {
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
}
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
}
}
Loaded 100 of 1529 files, more files were not shown because too many files have changed in this diff. Show more