Compare commits

..
Author SHA1 Message Date
l0ng-ai 90bb5576ae fix(editor): prevent duplicate Linux clipboard commands 2026-11-10 00:48:50 +08:00
957 changed files with 10072 additions and 52778 deletions

No files matched your search

+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
-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
+6 -6
View File
@@ -4232,10 +4232,8 @@ name = "ide-build"
version = "0.1.40"
dependencies = [
"blake3",
"chardetng",
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-core",
"serde",
"serde_json",
@@ -4263,7 +4261,6 @@ dependencies = [
name = "ide-clipboard"
version = "0.1.40"
dependencies = [
"gtk",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4276,6 +4273,7 @@ dependencies = [
"dunce",
"encoding_rs",
"libc",
"percent-encoding",
"serde",
"serde_json",
"specta",
@@ -4308,6 +4306,8 @@ dependencies = [
"ide-core",
"ide-jsonrpc",
"ide-language",
"ide-remote",
"ide-run",
"libc",
"memchr",
"parking_lot",
@@ -4455,6 +4455,7 @@ dependencies = [
"ignore",
"jsonschema",
"parking_lot",
"percent-encoding",
"quick-xml 0.42.0",
"regex-lite",
"regex-syntax",
@@ -12152,15 +12153,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",
+1 -1
View File
@@ -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"
+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=500" 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
+2 -82
View File
@@ -345,8 +345,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.
@@ -854,7 +852,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 +877,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 +1129,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 +1250,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()
};
@@ -2322,77 +2316,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() }
}
}
}
-9
View File
@@ -518,15 +518,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// Text was appended to entry `index` (an agent message or a thought): it read the first
/// `from` UTF-16 units of its text when the previous event for it was sent.
Append {
task: String,
subagent: Option<String>,
index: u32,
from: u32,
text: String,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
-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> {
-65
View File
@@ -366,35 +366,6 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
)
}
#[tokio::test(flavor = "multi_thread")]
async fn saved_conversation_reads_do_not_start_or_load_the_agent() {
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent.clone());
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Review the saved conversation"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.shutdown();
let restored_agent = Arc::new(FakeAgent::default());
let restored = manager(tmp.path(), restored_agent.clone());
assert!(!restored.entries(&task.id, None).unwrap().is_empty());
assert!(restored_agent.calls.lock().is_empty());
assert!(restored.entries("missing-task", None).is_err());
assert!(restored.entries(&task.id, Some("missing-subagent".into())).is_err());
restored.task(&task.id).unwrap().state.lock().conversation = Conversation::default();
assert!(restored.entries(&task.id, None).unwrap().is_empty());
restored.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn claude_commands_before_session_are_fetched_once_and_saved() {
@@ -2532,39 +2503,3 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
}
#[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
@@ -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();
+5 -25
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"];
@@ -499,7 +497,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
return Vec::new();
};
let active: Vec<String> =
@@ -519,7 +517,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = xml::read_text(settings)?;
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
@@ -545,7 +543,7 @@ impl Resolver {
fn pom(&mut self, path: &Path) -> Option<RawPom> {
self.poms
.entry(path.to_path_buf())
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
.clone()
}
@@ -736,7 +734,7 @@ pub(crate) fn launch_inputs(
return None;
}
out.push(path.clone());
let text = xml::read_text(&path)?;
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
@@ -1640,7 +1638,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Some(next) = xml::read_text(&pp) else { return chain };
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
@@ -2297,24 +2295,6 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn non_utf8_pom_with_declared_encoding_is_a_build() {
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
// instead of vanishing as "No maven project".
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (bytes, _, _) = encoding_rs::GBK.encode(
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
);
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
let model = load_with_repo(root, None);
assert_eq!(model.projects.len(), 1);
assert_eq!(model.projects[0].artifact_id, "应用");
assert_eq!(model.projects[0].display_name, "演示");
assert!(is_maven_project(root));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
-124
View File
@@ -1,124 +0,0 @@
//! On-demand sources resolution using the project's Maven launcher and repositories.
use super::MavenProject;
use crate::process;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub struct Artifact {
pub group: String,
pub name: String,
pub version: String,
pub sources: PathBuf,
}
/// Resolve only jars inside the selected local repository, including custom repositories.
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
let relative = jar.strip_prefix(repository).ok()?;
let parts: Vec<&str> = relative
.components()
.map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Option<_>>()?;
if parts.len() < 4 {
return None;
}
let n = parts.len();
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
let valid = |s: &str| {
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
};
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
return None;
}
Some(Artifact {
group: parts[..n - 3].join("."),
name: name.into(),
version: version.into(),
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
})
}
pub fn download(
root: &Path,
project: &MavenProject,
artifact: &Artifact,
repository: &Path,
cancel: &CancellationToken,
) -> IdeResult<Option<PathBuf>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
args.extend(project.options.clone());
args.extend([
format!("-Dmaven.repo.local={}", repository.display()),
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
"-Dtransitive=false".into(),
]);
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
if output.success
|| output.text.lines().any(|line| {
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
})
{
return Ok(None);
}
Err(IdeError::unavailable(
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_sources_coordinates_from_maven_layouts() {
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
for (group, name, version) in [
("org/apache/commons", "commons-lang3", "3.17.0"),
("com/example", "example-lib", "1.0-SNAPSHOT"),
("single", "lib", "1.0.0-rc.1"),
] {
let repository = Path::new(repository);
let dir = repository.join(group).join(name).join(version);
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
assert_eq!(found.group, group.replace('/', "."));
assert_eq!(found.name, name);
assert_eq!(found.version, version);
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
}
}
}
#[test]
fn rejects_non_artifacts_and_paths_outside_the_repository() {
let repository = Path::new("/m2/repository");
for path in [
"/m2/repository-other/g/a/1/a-1.jar",
"/m2/repository/g/a/1/a-1-sources.jar",
"/m2/repository/g/a/1/other.jar",
"/m2/repository/g/a/../a-1.jar",
"/m2/repository/g/a/1/a-1.pom",
"/m2/repository/g/a-1.jar",
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
] {
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
}
}
}
-66
View File
@@ -3,57 +3,6 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -261,19 +210,4 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
-3
View File
@@ -11,6 +11,3 @@ license.workspace = true
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+1 -60
View File
@@ -21,12 +21,6 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -171,12 +165,8 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(owner) } != 0 {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -240,29 +230,6 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -373,19 +340,6 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -416,19 +370,6 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+2 -1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
dunce.workspace = true
percent-encoding = "2"
encoding_rs.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -22,4 +23,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
-4
View File
@@ -42,10 +42,6 @@ pub enum IdeEvent {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
#[serde(default)]
#[specta(optional)]
ignore_rules: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
+1
View File
@@ -16,6 +16,7 @@ pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod path_mapping;
pub mod platform;
pub mod process;
pub mod project_dir;
+193
View File
@@ -0,0 +1,193 @@
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
const ESCAPES: &percent_encoding::AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'\\')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathMapping {
host: String,
target: String,
windows: bool,
}
#[derive(Clone, Copy)]
pub enum Direction {
ToTarget,
ToHost,
}
fn normalized(path: &str) -> String {
let path = path
.strip_prefix(r"\\?\UNC\")
.map(|p| format!("//{p}"))
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
let path = path.replace('\\', "/");
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
}
pub fn file_path(uri: &str) -> Option<String> {
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
return None;
}
let rest = &uri[7..];
let path = if rest.starts_with('/') {
rest.to_owned()
} else {
let (authority, path) = rest.split_once('/')?;
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
};
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
Some(path.to_owned())
}
pub fn file_uri(path: &str) -> String {
let path = normalized(path);
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
}
impl PathMapping {
pub fn new(host: &str, target: &str) -> Self {
let host = normalized(host);
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
Self { host, target: normalized(target), windows }
}
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
let uri_path = file_path(value);
let is_uri = uri_path.is_some();
let path = normalized(uri_path.as_deref().unwrap_or(value));
let (from, to) = match direction {
Direction::ToTarget => (&self.host, &self.target),
Direction::ToHost => (&self.target, &self.host),
};
let prefix = path.get(..from.len())?;
let same = if self.windows && matches!(direction, Direction::ToTarget) {
prefix.eq_ignore_ascii_case(from)
} else {
prefix == from
};
if !same {
return None;
}
let suffix = &path[from.len()..];
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
return None;
}
// Never let lexical parent segments escape either mapped root.
if suffix.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
Some(if is_uri {
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
format!("{}{suffix}", file_uri(&mapped))
} else if self.windows && matches!(direction, Direction::ToHost) {
mapped.replace('/', "\\")
} else {
mapped
})
}
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
rewrite_strings(value, &|s| self.map(s, direction));
}
}
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
/// Resolve tokens and command arguments remain opaque; source text is never a path.
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
match value {
Value::String(s) => {
if let Some(mapped) = map(s) {
*s = mapped;
}
}
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
Value::Object(fields) => {
let old = std::mem::take(fields);
for (key, mut value) in old {
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
rewrite_strings(&mut value, map);
}
fields.insert(map(&key).unwrap_or(key), value);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn roots_encoding_boundaries_and_windows() {
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
let m = PathMapping::new(host, "/app");
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
assert_eq!(
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
"file:///app/a%20b%23%25.php"
);
assert_eq!(
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
);
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
assert!(m.map(outside, Direction::ToHost).is_none());
}
}
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
let m = PathMapping::new("/home/me/project", "/app");
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
assert_eq!(
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
"file:///home/me/project/a%23b?query#fragment"
);
}
#[test]
fn nested_messages_resource_operations_and_opaque_payloads() {
let m = PathMapping::new("/host/project", "/app");
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
let original = v.clone();
m.rewrite(&mut v, Direction::ToTarget);
assert_eq!(v["rootUri"], "file:///app");
assert_eq!(v["rootPath"], "/app");
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
assert_eq!(v["data"], original["data"]);
assert_eq!(v["command"], original["command"]);
m.rewrite(&mut v, Direction::ToHost);
assert_eq!(v, original);
}
}
-226
View File
@@ -104,8 +104,6 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_job::Job;
#[cfg(windows)]
pub use windows_tree::ProcessTree;
@@ -253,167 +251,6 @@ mod windows_tree {
}
}
/// A kernel job object over one launched process tree. Everything a launch starts joins
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
/// started after the launch's snapshot was taken, orphans of a launcher that already
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
/// processes born in the window a process-enumeration kill would miss. Spawns assign
/// their root right after it starts; when the assignment fails (an enclosing job that
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
#[cfg(windows)]
mod windows_job {
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A job object holding one launch's processes. The handle is kept open for the
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
/// launch record (a run entry, a debug session) is finally dropped.
pub struct Job(OwnedHandle);
impl Job {
/// Takes the just-started `child` into a new job, so everything it starts is in
/// there too. `None` when the job could not be made or the process rejected the
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
pub fn for_child(child: &std::process::Child) -> Option<Job> {
// SAFETY: the child's own handle is a live process handle.
Self::from_handle(child.as_raw_handle())
}
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
/// a `tokio` child). `None` when the id no longer names an openable process — a
/// caller that holds the child cannot have had it reaped, so this is a dead or
/// already-gone launch.
pub fn for_pid(pid: u32) -> Option<Job> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: the handle was just opened and is owned here.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
Self::from_handle(handle.as_raw_handle())
}
fn from_handle(handle: RawHandle) -> Option<Job> {
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() || job == INVALID_HANDLE_VALUE {
return None;
}
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let job = unsafe { OwnedHandle::from_raw_handle(job) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
PerProcessUserTimeLimit: 0,
PerJobUserTimeLimit: 0,
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
MinimumWorkingSetSize: 0,
MaximumWorkingSetSize: 0,
ActiveProcessLimit: 0,
Affinity: 0,
PriorityClass: 0,
SchedulingClass: 0,
},
IoInfo: IO_COUNTERS {
ReadOperationCount: 0,
WriteOperationCount: 0,
OtherOperationCount: 0,
ReadTransferCount: 0,
WriteTransferCount: 0,
OtherTransferCount: 0,
},
ProcessMemoryLimit: 0,
JobMemoryLimit: 0,
PeakProcessMemoryUsed: 0,
PeakJobMemoryUsed: 0,
};
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let ok = unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&mut limits as *mut _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
return None;
}
// SAFETY: the process handle belongs to the just-spawned child and outlives
// this call; the job handle is owned here.
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
if ok == 0 {
return None;
}
Some(Job(job))
}
/// Adds another just-started root of the same launch to the job (a Go build's
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
/// take; the process then relies on its own kill.
pub fn assign_pid(&self, pid: u32) -> bool {
// SAFETY: plain call; the handle is opened only to assign it, and the job
// handle is owned and valid.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return false;
}
// SAFETY: both handles are valid for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
// SAFETY: the process handle was opened above and is owned here.
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
ok != 0
}
/// Terminates every process in the job. `false` when the call failed (the job
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
/// the tree is gone.
pub fn terminate(&self) -> bool {
// SAFETY: a job handle owned here.
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
}
/// Whether any process of the launch is still in the job. When the job's state
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
/// [`Job::terminate`] does not depend on the answer.
pub fn is_running(&self) -> bool {
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
// longer, which means there are processes.
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
NumberOfAssignedProcesses: 0,
NumberOfProcessIdsInList: 0,
ProcessIdList: [0],
};
let mut returned = 0u32;
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
let ok = unsafe {
QueryInformationJobObject(
self.0.as_raw_handle(),
JobObjectBasicProcessIdList,
&mut list as *mut _ as *mut _,
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
&mut returned,
)
};
if ok != 0 {
list.NumberOfProcessIdsInList > 0
} else {
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
}
}
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -1119,66 +956,3 @@ mod kill_tests {
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[cfg(all(test, windows))]
mod job_tests {
use super::*;
use std::os::windows::process::CommandExt;
use std::process::Stdio;
use std::time::Instant;
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
/// application: the job must reach both.
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let child = cmd.spawn().unwrap();
let job = Job::for_child(&child).expect("the job takes the launch");
let tree = ProcessTree::capture(child.id());
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
(child, job, tree)
}
/// Termination is asynchronous: the job reports its processes gone while their
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
fn assert_gone(tree: &ProcessTree) {
let until = Instant::now() + Duration::from_secs(10);
while tree.is_running() {
assert!(Instant::now() < until, "the tree survived the job");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn job_terminates_the_tree_the_root_started() {
let (mut child, job, tree) = launch_tree();
assert!(job.is_running());
assert!(job.terminate(), "TerminateJobObject succeeds");
// The captured tree's handles survive the root's death, so the wait below is the
// grandchild's, not a reused pid's.
assert_gone(&tree);
assert!(!job.is_running());
let _ = child.wait();
}
#[test]
fn a_later_spawned_root_joins_the_job() {
let (mut first, job, _) = launch_tree();
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let mut second = cmd.spawn().unwrap();
let second_tree = ProcessTree::capture(second.id());
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
assert!(job.terminate());
assert_gone(&second_tree);
let _ = first.wait();
let _ = second.wait();
}
#[test]
fn a_dead_pid_makes_no_job() {
// An id of a process this test cannot open (or that does not exist) gives no job,
// which is the callers' cue for the `taskkill /T /F` fallback.
assert!(Job::for_pid(u32::MAX).is_none());
}
}
+1 -12
View File
@@ -201,18 +201,7 @@ pub struct PluginRunTarget {
pub args: Vec<String>,
pub workdir_args: Vec<String>,
pub container_root: Option<String>,
pub commands: Vec<PluginCommandOverride>,
}
/// Per-command argv prefix and optional replacement working-directory arguments.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommandOverride {
pub name: String,
pub args: Vec<String>,
/// None inherits the target's list; Some([]) suppresses it.
#[serde(default)]
pub workdir_args: Option<Vec<String>>,
pub commands: Vec<(String, Vec<String>)>,
}
#[cfg(test)]
+16 -218
View File
@@ -48,6 +48,8 @@ pub struct Settings {
/// Per language server, by name (`rust-analyzer`): launch and protocol
/// options on top of the language definition, or a server defined here.
pub language_servers: BTreeMap<String, LanguageServerSettings>,
/// Project language id → server and plugin target. Empty target uses the host.
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
/// Editor › Inspections profiles.
pub inspections: InspectionSettings,
/// Editor › Natural Languages › Spelling.
@@ -59,16 +61,16 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Settings › Agents.
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
/// Tools › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// 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 +82,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 {
@@ -249,6 +141,14 @@ pub struct ToolSettings {
pub path: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerTarget {
pub target: String,
/// Empty selects the first configured server for the language.
pub server: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageServerSettings {
@@ -343,6 +243,7 @@ impl Default for Settings {
external_tools: Vec::new(),
tools: BTreeMap::new(),
language_servers: BTreeMap::new(),
language_server_targets: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
@@ -376,10 +277,6 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// When on, the last window closes instead of showing the Welcome screen.
pub close_without_welcome: bool,
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
pub close_tabs_with_window: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
@@ -403,8 +300,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,
@@ -468,21 +363,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 {
@@ -531,9 +411,6 @@ pub struct AppearanceSettings {
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
/// the status bar by default, above the editor, or not at all.
pub navigation_bar: NavigationBarPosition,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -606,7 +483,6 @@ impl Default for AppearanceSettings {
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -644,9 +520,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 +564,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 +608,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 +635,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -794,7 +656,6 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
@@ -816,22 +677,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")]
@@ -1334,26 +1183,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 +1245,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,22 +1274,6 @@ mod tests {
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
let settings: Settings = serde_json::from_str(
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
)
.unwrap();
assert_eq!(settings.editor.font_size, 17.0);
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
assert_eq!(
settings.agents.defaults,
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
+2
View File
@@ -24,5 +24,7 @@ tracing.workspace = true
libc = "0.2"
[dev-dependencies]
ide-remote.workspace = true
ide-run.workspace = true
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
+10 -202
View File
@@ -31,6 +31,7 @@ pub enum Transport {
/// A fully resolved adapter command line.
#[derive(Clone, Debug)]
pub struct AdapterSpec {
pub target: Option<crate::target::TargetMapping>,
pub adapter: String,
pub command: String,
pub args: Vec<String>,
@@ -51,6 +52,7 @@ pub struct AdapterSpec {
/// Built-in adapters used when no language definition names `adapter`.
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
target_placement: None,
adapter: adapter.to_owned(),
command: command.to_owned(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -101,14 +103,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
"gdb",
&["-i", "dap"],
"stdio",
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
),
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
@@ -135,7 +129,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
"delve" => "go",
"netcoredbg" => "coreclr",
"lldb-dap" => "lldb-dap",
"gdb" => "gdb",
"java-debug" => "java",
_ => return None,
})
@@ -143,6 +136,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
AdapterSpec {
target: None,
adapter: config.adapter.clone(),
command: config.command.clone(),
args: config.args.clone(),
@@ -341,89 +335,6 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
)))
}
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
/// "Interpreter `dap' unrecognized".
const GDB_MIN_MAJOR: u32 = 14;
/// `(major, minor)` of the first version number in the output of `gdb --version`
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
let line = output.lines().next()?;
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
if !b[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let major: u32 = line[start..i].parse().ok()?;
let mut minor = 0;
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
i += 1;
let ms = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
minor = line[ms..i].parse().unwrap_or(0);
}
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
continue;
}
return Some((major, minor));
}
None
}
/// Probe the already resolved and verified executable with its launch environment.
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
let mut probe = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
probe.env("PATH", path);
}
ide_core::process::prepare(probe.as_std_mut());
let probe = probe
.arg("--version")
.current_dir(&spec.cwd)
.envs(spec.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
return Ok(None);
};
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
}
/// Refuse old GDB versions and report whether launch must follow configurationDone.
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
let Some((major, minor)) = gdb_version(binary, spec).await? else {
return Ok(false);
};
if major >= GDB_MIN_MAJOR {
return Ok(major == 15);
}
let mut msg = format!(
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
this one has no DAP interpreter (`gdb -i dap`)"
);
if let Some(hint) = &spec.install_hint {
msg.push_str(". ");
msg.push_str(hint);
}
Err(IdeError::unavailable(msg))
}
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
/// than a program: it has no process of its own and is reached through `debugServer`.
pub fn is_plugin_bundle(command: &str) -> bool {
@@ -457,8 +368,6 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
/// A connected adapter: byte streams plus the process (if we spawned one).
pub struct AdapterIo {
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
pub launch_after_configuration: bool,
pub reader: BoxReader,
pub writer: BoxWriter,
pub child: Option<Child>,
@@ -510,7 +419,6 @@ impl AdapterLauncher for ProcessLauncher {
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -534,7 +442,6 @@ impl AdapterLauncher for ProcessLauncher {
let _ = stream.set_nodelay(true);
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -543,7 +450,7 @@ impl AdapterLauncher for ProcessLauncher {
program_output: None,
});
}
if spec.adapter == DEBUGPY_TOOL {
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
prepare_debugpy(&mut spec).await?;
}
if is_plugin_bundle(&spec.command) {
@@ -563,15 +470,9 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let launch_after_configuration =
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => {
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
io.launch_after_configuration = launch_after_configuration;
Ok(io)
}
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
@@ -588,10 +489,7 @@ impl AdapterLauncher for ProcessLauncher {
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(mut io) => {
io.launch_after_configuration = launch_after_configuration;
return Ok(io);
}
Ok(io) => return Ok(io),
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
@@ -627,10 +525,9 @@ async fn spawn_adapter(
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
// Every adapter leads its own process group: the program an adapter launches itself
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
ide_core::process::own_group(cmd.as_std_mut());
if spec.adapter == "rdbg" {
ide_core::process::own_group(cmd.as_std_mut());
}
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
@@ -654,7 +551,6 @@ async fn spawn_adapter(
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
@@ -686,7 +582,6 @@ async fn spawn_adapter(
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
@@ -844,102 +739,15 @@ mod tests {
#[test]
fn builtins_cover_common_adapters() {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
let cfg = builtin(name).unwrap();
assert!(cfg.install_hint.is_some());
}
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
assert_eq!(delve.transport, Transport::Tcp);
// GDB's own DAP interpreter over stdio, with the required version in its hint.
let gdb = builtin("gdb").unwrap();
assert_eq!(gdb.command, "gdb");
assert_eq!(gdb.args, ["-i", "dap"]);
assert_eq!(gdb.transport, "stdio");
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
assert_eq!(default_type("gdb"), Some("gdb"));
assert!(builtin("nope").is_none());
}
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
#[test]
fn parses_gdb_versions() {
for (out, want) in [
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
("GNU gdb 14", Some((14, 0))),
("no version here", None),
("", None),
] {
assert_eq!(parse_gdb_version(out), want, "{out}");
}
}
#[tokio::test]
async fn gdb_probe_never_executes_a_changed_pinned_program() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
std::fs::write(&fake, "approved").unwrap();
let pin = ProgramPin::approve(&fake).unwrap();
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
spec.command = fake.to_string_lossy().into_owned();
spec.pinned = Some(pin);
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
}
#[tokio::test]
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let pin = ProgramPin::approve(&fake).unwrap();
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
// Only the approved path is executable; the symbolic command must never be probed.
spec.command = "definitely-not-a-real-binary-openidea".into();
spec.pinned = Some(pin.clone());
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
let result = ProcessLauncher.launch(spec, None).await;
if version == "12.1" {
let error = result.err().expect("GDB 12 has no DAP interpreter");
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
assert!(error.message.contains("12.1"), "{}", error.message);
} else {
let mut io = result.unwrap();
assert_eq!(io.launch_after_configuration, deferred, "{version}");
let mut output = String::new();
io.reader.read_to_string(&mut output).await.unwrap();
assert_eq!(output.trim(), "adapter");
io.child.as_mut().unwrap().wait().await.unwrap();
}
}
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
}
#[test]
fn free_port_is_nonzero() {
assert_ne!(free_port().unwrap(), 0);
+24 -2
View File
@@ -71,6 +71,7 @@ pub enum Incoming {
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
struct Inner {
mapping: Option<crate::target::TargetMapping>,
out: mpsc::UnboundedSender<Vec<u8>>,
pending: Mutex<Pending>,
seq: AtomicI64,
@@ -106,9 +107,22 @@ impl Connection {
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
/// own output on stdout, decoded) to `on_output`.
pub fn start_with_output<R, W>(
reader: R,
writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start_with_target(reader, writer, on_output, None)
}
pub fn start_with_target<R, W>(
reader: R,
mut writer: W,
on_output: Box<dyn Fn(String) + Send + Sync>,
target: Option<crate::target::TargetMapping>,
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
where
R: AsyncRead + Unpin + Send + 'static,
@@ -117,6 +131,7 @@ impl Connection {
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (in_tx, in_rx) = mpsc::unbounded_channel();
let inner = Arc::new(Inner {
mapping: target,
out: out_tx,
pending: Mutex::new(HashMap::new()),
seq: AtomicI64::new(1),
@@ -187,7 +202,11 @@ impl Connection {
if self.is_closed() {
return Err(DapError::Closed);
}
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
let mut value = value.clone();
if let Some(mapping) = self.inner.mapping.as_ref() {
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
}
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
self.inner.out.send(body).map_err(|_| DapError::Closed)
}
@@ -246,13 +265,16 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
let mut msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
if let Some(mapping) = inner.mapping.as_ref() {
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
}
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
+70 -55
View File
@@ -16,6 +16,7 @@ pub mod jdwp;
pub mod kotlin;
mod kotlin_jdwp;
mod session;
pub mod target;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
use connection::{Connection, DapError};
@@ -696,6 +697,17 @@ impl DebugManager {
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
}
/// Configure a target adapter before any host tool resolution or launch.
pub async fn start_prepared(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
@@ -710,6 +722,22 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
prepare(&mut spec, &mut arguments)?;
if spec.target.is_some() {
return self
.start_session(
DebugSessionId::next(),
owner,
config.name,
config.adapter,
None,
config.request,
arguments,
spec,
None,
)
.await;
}
if config.adapter == kotlin::ADAPTER
&& let Some(home) = arguments
.as_object_mut()
@@ -718,10 +746,6 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
// Delve looks up Go before applying the debuggee's launch environment.
if config.adapter == "delve" {
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
@@ -754,18 +778,6 @@ impl DebugManager {
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
// instead belong on the adapter process, which the inferior inherits. Explicit
// launch JSON keeps GDB's replacement semantics.
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
});
if !explicit_env {
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
arguments.as_object_mut().unwrap().remove("env");
}
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -874,12 +886,22 @@ impl DebugManager {
if request != "launch" && request != "attach" {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
if spec.target.is_some() {
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
} else {
e
}
})?;
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
Connection::start_with_target(
io.reader,
io.writer,
Box::new(move |line| adapter::push_line(&stderr, line)),
spec.target.clone(),
)
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
@@ -913,13 +935,16 @@ impl DebugManager {
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments, launch_after_configuration) => result,
result = self.configure(&session, arguments) => result,
};
match configured {
Ok(()) => Ok(id),
Err(e) => {
let e = if session.conn.is_closed() {
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
if session.spec.target.is_some() {
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
}
let tail = stderr.lock().join("\n");
if !tail.is_empty() {
msg.push_str(":\n");
@@ -930,6 +955,17 @@ impl DebugManager {
msg.push_str(hint);
}
IdeError::unavailable(msg)
} else if session.spec.target.is_some() {
IdeError::new(
e.kind,
format!(
"{}\nargv: {:?} {:?}\n{}",
e.message,
session.spec.command,
session.spec.args,
stderr.lock().join("\n")
),
)
} else {
e
};
@@ -940,7 +976,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
@@ -967,8 +1003,8 @@ impl DebugManager {
"pathFormat": "path",
"supportsVariableType": true,
"supportsVariablePaging": false,
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsRunInTerminalRequest": s.spec.target.is_none(),
"supportsStartDebuggingRequest": s.spec.target.is_none(),
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
@@ -1006,28 +1042,16 @@ impl DebugManager {
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
let mut launch = if launch_in_config {
None
} else {
let reply = s.conn.send_request(request, arguments.clone())?;
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
};
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = match launch.as_mut() {
Some(launch) => tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = launch => {
r?;
launch_result = Some(());
false
}
},
None => {
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
true
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
}
};
if !got_initialized {
@@ -1085,17 +1109,8 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
}
}
None => {
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
let reply = s.conn.send_request(request, arguments)?;
reply.await.unwrap_or(Err(DapError::Closed))?;
}
if launch_result.is_none() {
launch.await?;
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
@@ -1145,7 +1160,7 @@ impl DebugManager {
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false).await;
crate::session::signal_debuggee(s, false);
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
+21 -127
View File
@@ -45,13 +45,6 @@ pub(crate) struct Session {
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// The job object over the adapter and everything it started: the program an adapter
/// like java-debug launches is its child, so a forced stop (and the session's
/// cleanup) ends the whole tree even when the adapter exited first and the reported
/// pid no longer names it. `None` when the assignment failed; the kill then falls
/// back to the captured process tree.
#[cfg(windows)]
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
@@ -98,10 +91,6 @@ impl Session {
port: Option<u16>,
child: Option<Child>,
) -> Self {
// Before `child` moves into the session: the adapter is the root of the job, so
// everything it starts (java-debug's JVM) is in there by inheritance.
#[cfg(windows)]
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
Self {
id,
owner,
@@ -116,8 +105,6 @@ impl Session {
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
@@ -295,24 +282,6 @@ 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()
@@ -326,9 +295,6 @@ impl Session {
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -380,36 +346,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 +368,15 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
// Console programs have no external graceful signal; the adapter gets its chance
// first. The job ends the program and everything it started, members included
// that the captured tree never saw.
if force {
if let Some(job) = s.job.lock().as_ref() {
job.terminate();
}
// A separately spawned program may have rejected assignment to the adapter's job.
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
// Console programs have no external graceful signal; the adapter gets its chance first.
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// The process groups the session spawned roots for: the adapter's (the program it
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
#[cfg(unix)]
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
let mut groups = Vec::new();
let mut push = |id: Option<u32>| {
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
groups.push(pid);
}
};
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
/// Whether the launch is still running: the stop's captured tree when there is one,
/// otherwise the session's job (the adapter and everything it started).
#[cfg(windows)]
fn launch_running(s: &Arc<Session>) -> bool {
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.is_running()
} else {
s.job.lock().as_ref().is_some_and(|job| job.is_running())
}
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
@@ -480,19 +390,19 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
#[cfg(unix)]
{
use std::time::Instant;
let mut targets = s.stop_pids.lock().clone();
if targets.is_empty() {
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
}
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true).await;
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true).await;
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -502,12 +412,9 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
// The captured tree is the oracle when it was captured; otherwise the job (every
// process the launch started) is, so a failed capture can no longer end the wait
// while the program still holds its port.
while launch_running(s) {
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
signal_debuggee(s, true);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
@@ -700,6 +607,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
}
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
if s.spec.target.is_some() {
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
return;
}
match command {
"runInTerminal" => {
let result = run_in_terminal(s, &args);
@@ -786,14 +697,6 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
}
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
// The program joins the session's job, so Stop reaches what it starts (its children),
// though only the program itself is the session's child.
#[cfg(windows)]
if let Some(pid) = child.process_id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
@@ -961,16 +864,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
}
}
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
// forced stop), the session's job on Windows, so a stop reaches what it forks.
ide_core::process::own_group(cmd.as_std_mut());
let mut child = cmd.spawn()?;
#[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");
}
+72
View File
@@ -0,0 +1,72 @@
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
use serde_json::Value;
#[derive(Clone, Debug)]
pub struct TargetMapping {
pub paths: PathMapping,
pub external_prefix: String,
}
impl TargetMapping {
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
match value {
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
Value::Object(fields) => {
for (key, value) in fields {
if key == "pathMappings" {
continue;
}
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
if let Some(path) = value.as_str() {
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
if let Some(decoded) =
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
{
*value = decoded.into();
}
} else if let Some(mapped) = self.paths.map(path, direction) {
*value = mapped.into();
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
*value = format!(
"{}{}",
self.external_prefix,
file_uri(path).trim_start_matches("file:///")
)
.into();
}
}
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
self.rewrite(value, direction);
}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
let m = TargetMapping {
paths: PathMapping::new("C:/project", "/app"),
external_prefix: "openidea-target://test/0/".into(),
};
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
m.rewrite(&mut request, Direction::ToTarget);
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
assert_eq!(request["arguments"]["cwd"], "/app");
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
m.rewrite(&mut response, Direction::ToHost);
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
assert_eq!(response["body"]["output"], "/app/a.py");
}
}
+19 -187
View File
@@ -126,10 +126,6 @@ struct Script {
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 {
@@ -149,8 +145,6 @@ impl Default for Script {
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
}
}
}
@@ -165,12 +159,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 +180,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,37 +200,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;
}
@@ -311,14 +289,6 @@ 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);
}
@@ -540,60 +510,6 @@ async fn stop_cancels_pending_launch_and_attach() {
}
}
/// 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"] {
@@ -601,6 +517,7 @@ async fn startup_still_reports_adapter_failure_and_connection_closure() {
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}");
@@ -1123,39 +1040,6 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
@@ -1369,6 +1253,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
target_placement: None,
adapter: "godot".into(),
command: command.into(),
args: args.iter().map(|a| (*a).to_owned()).collect(),
@@ -2044,7 +1929,6 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
@@ -2079,55 +1963,3 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
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);
}
+30
View File
@@ -0,0 +1,30 @@
import sys,json,os,socket
root=os.environ.get("FAKE_ROOT","/app")
r=sys.stdin.buffer; w=sys.stdout.buffer
seq=0
def send(v):
global seq
seq+=1; v['seq']=seq
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
while True:
line=r.readline()
if not line: break
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
if c=='launch':
if 'program' in a: assert a['program']==root+'/main.py',a
if os.environ.get('FAKE_CONNECT_BACK'):
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
assert a['cwd']==root,a
send({'type':'event','event':'initialized'})
if c=='setBreakpoints':
assert a['source']['path']==root+'/main.py',a
body={'breakpoints':[{'verified':True,'line':3}]}
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
if c=='disconnect': break
+17 -3
View File
@@ -93,9 +93,23 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.start_prepared(
listen_config(port, &served, &served),
SessionOwner::new("main", sink.clone()),
|_| None,
|spec, _| {
spec.command = adapter.clone();
spec.cwd = local.clone();
spec.target = Some(ide_dap::target::TargetMapping {
paths: ide_core::path_mapping::PathMapping::new(
&local.to_string_lossy(),
&served.to_string_lossy(),
),
external_prefix: "openidea-target://php/0/".into(),
});
Ok(())
},
)
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
-20
View File
@@ -151,16 +151,6 @@ fn cases(dir: &Path) -> Vec<Case> {
field: None,
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
},
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
Case {
adapter: "gdb",
source: c.join("main.c"),
loop_line: 13,
after_loop: 15,
function: "parse",
field: None,
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
},
]);
out
}
@@ -240,16 +230,6 @@ fn installed(adapter: &str) -> bool {
"js-debug" => "js-debug-adapter",
"delve" => "dlv",
"java-debug" => return true,
// Only a GDB with the DAP interpreter (14+) counts as installed.
"gdb" => {
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(adapter::parse_gdb_version)
.is_some_and(|(major, _)| major >= 14)
});
}
other => other,
};
adapter::resolve_binary(cmd, Path::new("/")).is_some()
+130
View File
@@ -0,0 +1,130 @@
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
#![cfg(unix)]
use ide_core::{SharedSink, path_mapping::PathMapping};
use ide_dap::{target::TargetMapping, *};
use ide_language::LanguageRegistry;
use std::{collections::BTreeMap, sync::Arc};
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
#[tokio::test]
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let adapter = root.join("adapter.py");
std::fs::write(&adapter, ADAPTER).unwrap();
let wrapper = root.join("wrapper");
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let target = ide_core::remote_settings::PluginRunTarget {
id: "fake".into(),
name: "Fake".into(),
program: "wrapper".into(),
args: vec![],
workdir_args: vec![],
container_root: Some("/app".into()),
commands: vec![],
};
// Keep the fixture adapter outside mapped argv; this is the container's executable.
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Target".into(),
adapter: "debugpy".into(),
request: "launch".into(),
program: Some(root.join("main.py").to_string_lossy().into_owned()),
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: None,
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = run.command;
spec.args = run.args;
spec.cwd = root;
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(
frames[0].path.as_ref().unwrap().to_string_lossy(),
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
);
assert_eq!(frames[0].line, 2);
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let port = ide_dap::adapter::free_port().unwrap();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
manager
.set_breakpoints(
root.join("main.py"),
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
)
.await
.unwrap();
let config = LaunchConfig {
name: "Connect back".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(root.clone().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
};
let sink: SharedSink = Arc::new(|_| {});
let mapping = TargetMapping {
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
external_prefix: "openidea-target://fake/0/".into(),
};
let id = manager
.start_prepared(
config,
SessionOwner::new("test", sink),
|_| None,
move |spec, _| {
spec.command = "/usr/bin/python3".into();
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
spec.target = Some(mapping);
Ok(())
},
)
.await
.unwrap();
let code =
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
let frames = manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
assert!(child.wait().await.unwrap().success());
manager.stop(id).await.unwrap();
}
-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() };
+1 -2
View File
@@ -270,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -559,7 +559,6 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
-32
View File
@@ -38,10 +38,6 @@ pub enum Capability {
Clipboard,
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
Net(String),
/// Ask the user when the first request goes to an HTTPS (or loopback) host
/// the manifest does not list; each answer applies to one host. Plain
/// HTTP elsewhere always needs `net.http:<host>`.
NetAsk,
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
@@ -75,7 +71,6 @@ impl Capability {
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetAsk
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
@@ -109,10 +104,6 @@ impl Capability {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetAsk => {
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
.into()
}
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
@@ -239,7 +230,6 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetAsk => f.write_str("net:ask"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
@@ -261,9 +251,6 @@ impl FromStr for Capability {
}
return match kind {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -464,25 +451,6 @@ mod command_tests {
}
}
#[cfg(test)]
mod net_ask_tests {
use super::*;
#[test]
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
let cap: Capability = "net:ask".parse().unwrap();
assert_eq!(cap, Capability::NetAsk);
assert_eq!(cap.to_string(), "net:ask");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
// It asks; it does not itself connect anywhere or pre-approve a host.
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
-62
View File
@@ -53,9 +53,6 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
@@ -204,9 +201,6 @@ impl Ledger {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
@@ -231,9 +225,6 @@ impl Ledger {
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
@@ -261,12 +252,6 @@ impl Ledger {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Net(host) = cap {
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
}
if cap == &Capability::NetAsk {
grant.net_hosts.clear();
}
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
@@ -277,19 +262,6 @@ impl Ledger {
}
}
/// Remembers one exact host without granting a manifest capability.
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
return false;
}
grant.net_hosts.insert(host.to_owned())
}
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
@@ -538,40 +510,6 @@ mod tests {
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
let r = req("1.0.0", "h", &["net:ask"], &[]);
l.confirm(&r, &[], BTreeMap::new());
let host = "api.example.com";
assert!(l.remember_net_host(&r.subject, host));
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
l.save().unwrap();
l = Ledger::load(tmp.path());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
l.confirm(&r, &[], BTreeMap::new());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
for confirm in [false, true] {
l.remember_net_host(&r.subject, host);
// A newly declared host must not inherit an ask grant after net:ask is removed.
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
if confirm {
l.confirm(&update, &[], BTreeMap::new());
} else {
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
}
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
l.remember_net_host(&r.subject, host);
l.set(&r.subject, &Capability::NetAsk, false);
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
-24
View File
@@ -338,9 +338,6 @@ impl Manifest {
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -721,24 +718,3 @@ mod commands_api_tests {
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
}
}
#[cfg(test)]
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.5" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
}
}
}
}
}
+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
@@ -10,6 +10,7 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
percent-encoding = "2"
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.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"
+3 -48
View File
@@ -26,8 +26,10 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
let probe = dir.join(".openidea-write-check");
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.and_then(|()| fs::write(&probe, b""))
.and_then(|()| fs::remove_file(&probe))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -325,53 +327,6 @@ mod tests {
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"] {
+2 -2
View File
@@ -56,7 +56,7 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
pub trust: trust::TrustStore,
@@ -125,7 +125,7 @@ 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(),
plugin_host: Arc::default(),
mcp_executable: RwLock::default(),
mcp_reports: Default::default(),
mcp_requests: Default::default(),
+10 -1
View File
@@ -1,4 +1,5 @@
mod output;
mod target;
use crate::Host;
use crate::rpc::Dispatcher;
@@ -9,6 +10,15 @@ use std::path::PathBuf;
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
host.require_trust(window)?;
let target_id = config
.extra_json
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
if let Some(id) = target_id {
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
return target::start(host, window, config, owner, id).await;
}
let mut config = match_java_debug_encoding(config, cfg!(windows));
if config.adapter == "debugpy"
&& config.request == "launch"
@@ -28,7 +38,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,
+102
View File
@@ -0,0 +1,102 @@
use crate::Host;
use ide_core::path_mapping::PathMapping;
use ide_core::{IdeError, IdeResult};
use ide_dap::target::TargetMapping;
use ide_dap::{LaunchConfig, SessionOwner};
use ide_language::ProgramPin;
use serde_json::json;
pub(super) async fn start(
host: &Host,
window: &str,
mut config: LaunchConfig,
owner: SessionOwner,
id: String,
) -> IdeResult<ide_core::DebugSessionId> {
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let registration = plugins
.run_targets(&project.root)
.into_iter()
.find(|t| t.id == id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
let container = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
let placement = dbg.target_placement.as_deref().unwrap_or("");
if !matches!(placement, "connect-back" | "adapter-in-target")
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
{
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
}
let mapping = TargetMapping {
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
external_prefix: format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
),
};
let mut extra: serde_json::Value = config
.extra_json
.as_deref()
.map(serde_json::from_str)
.transpose()
.map_err(|e| IdeError::invalid(e.to_string()))?
.unwrap_or(json!({}));
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
// DAP paths already use target coordinates; adapter-native mappings stay identity.
if placement == "connect-back" {
config.program = None;
config.args.clear();
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
extra.as_object_mut().unwrap().remove(key);
}
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
extra["port"] = json!(9003);
}
extra["hostname"] = json!("0.0.0.0");
extra["pathMappings"] = json!({container.clone(): container.clone()});
} else {
for key in ["debugLauncherPython", "pythonPath"] {
extra.as_object_mut().unwrap().remove(key);
}
extra["console"] = json!("internalConsole");
extra["python"] = json!(dbg.command);
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
}
config.extra_json = Some(extra.to_string());
let wrapped = if placement == "adapter-in-target" {
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
let root = project.root.clone();
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
} else {
None
};
host.require_trust(window)?;
let root = project.root.clone();
let settings = project.settings.effective();
host.debugger
.start_prepared(
config,
owner,
|_| None,
move |spec, _| {
if let Some(run) = wrapped {
spec.command = run.command;
spec.args = run.args;
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
spec.env = run.env.into_iter().collect();
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
spec.command = command.to_string_lossy().into_owned();
}
spec.target = Some(mapping);
Ok(())
},
)
.await
}
+1 -76
View File
@@ -88,28 +88,8 @@ 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)
@@ -254,22 +234,6 @@ mod tests {
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();
@@ -393,45 +357,6 @@ mod tests {
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();
+36 -137
View File
@@ -197,7 +197,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 }),
@@ -546,83 +545,43 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
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?
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
host.require_trust(window)?;
let project = host.project(window)?;
let plugins =
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
for registration in plugins.run_targets(&project.root) {
let prefix = format!(
"openidea-target://{}/0/",
percent_encoding::utf8_percent_encode(&registration.id, percent_encoding::NON_ALPHANUMERIC)
);
if let Some(suffix) = uri.strip_prefix(&prefix) {
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let container_root = registration
.target
.container_root
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let target = ide_language::ServerTarget {
id: registration.id,
origin: None,
project_root: project.root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
container_root,
};
let title = std::path::Path::new(&path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let language_id = host.languages.detect(std::path::Path::new(&path), None);
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
}
};
match sources {
Some(sources) => project.lsp.library_source_location(&uri, sources, pos, &cancel).await,
None => Ok(None),
}
return Err(IdeError::unavailable("plugin run target is no longer available"));
}
.await;
host.end_request(request);
result
host.project(window)?.lsp.virtual_document(&uri).await
}
/// Opens compiled classes through JDT, with project source taking priority on disk.
@@ -863,8 +822,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 +843,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 +869,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());
}
@@ -1690,14 +1646,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(
@@ -2193,55 +2141,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;
+120
View File
@@ -0,0 +1,120 @@
//! Project language-server targets, shared by desktop and openidea-server.
use crate::plugins::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageServerConfig, ServerTarget};
use std::path::Path;
pub(crate) fn configure(
plugins: &PluginHost,
root: &Path,
id: &str,
cfg: &mut LanguageServerConfig,
) -> IdeResult<bool> {
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
let container_root = registration.target.container_root.clone().unwrap_or_default();
cfg.target = Some(ServerTarget {
id: id.into(),
origin: None,
project_root: root.into(),
container_root: container_root.clone(),
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
});
if container_root.is_empty() {
return Err(IdeError::invalid("language server targets require a container root"));
}
let run = command(&cfg.command, cfg.args.clone());
let wrapped = plugins.prepare_run_target(root, id, &run)?;
cfg.command = wrapped.command;
cfg.args = wrapped.args;
cfg.connect = None;
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
Ok(true)
}
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
serde_json::from_value(serde_json::json!({
"id":"language-server", "name":"Language server", "kind":"shell",
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
}))
.expect("valid internal run configuration")
}
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
let registration = plugins
.run_targets(&target.project_root)
.into_iter()
.find(|t| t.id == target.id)
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
if registration.target.container_root.as_deref() != Some(&target.container_root) {
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
}
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
wrapped.args.extend(["--".into(), path.into()]);
capture(&wrapped, &target.project_root)
}
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
const LIMIT: u64 = 4 * 1024 * 1024;
let mut cmd = Command::new(&run.command);
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
let mut child = cmd
.args(&run.args)
.current_dir(root)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let overflow = Arc::new(AtomicBool::new(false));
let large = overflow.clone();
let output = std::thread::spawn(move || {
let mut bytes = Vec::new();
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
result.map(|_| bytes)
});
let errors = std::thread::spawn(move || {
let mut stderr = stderr;
let mut bytes = Vec::new();
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
bytes
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let status = loop {
if output.is_finished()
&& errors.is_finished()
&& let Ok(Some(status)) = child.try_wait()
{
break Ok(status);
}
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
_ => {
ide_core::process::kill_tree_of(child.id());
let _ = child.kill();
let _ = child.wait();
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
}
}
};
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
let stderr = errors.join().unwrap_or_default();
if !status?.success() {
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
}
if bytes.len() as u64 > LIMIT {
return Err(IdeError::invalid("container document exceeds 4 MiB"));
}
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
}
+2
View File
@@ -98,3 +98,5 @@ impl Host {
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
}
pub(crate) mod lsp_target;
+1 -1
View File
@@ -222,7 +222,7 @@ pub fn set_settings(
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
+2 -33
View File
@@ -203,22 +203,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);
}
let result = start_inner(host, window, config, on_output).await;
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
plugins.run_operation_started(window, &operation, info.id);
}
@@ -230,11 +216,7 @@ async fn start_inner(
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() {
@@ -277,20 +259,7 @@ async fn start_inner(
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());
}
let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
project.require_trust()?;
return host.processes.start(
&project.root,
+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 });
+15 -251
View File
@@ -6,7 +6,6 @@ use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Capability;
use ide_extension::Package;
use ide_extension::ledger::{Ledger, Subject};
use ide_language::ProgramPin;
use ide_plugin::{
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
@@ -18,7 +17,7 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command as StdCommand, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Largest storage of one plugin (keys + values).
@@ -164,10 +163,16 @@ impl SecretStore for FileSecretStore {
None => map.remove(name),
};
let file = self.file(plugin);
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
ide_vfs::create_private_dir(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
replace_entry(&file, &bytes).map_err(|e| e.to_string())
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
}
}
@@ -496,10 +501,6 @@ pub(super) struct Shared {
pub(super) prompts: Mutex<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
pub(super) prompts_blocked: Mutex<BTreeSet<String>>,
/// One network question per plugin; refusals throttle all hosts for 30 seconds.
net_prompts: Mutex<HashMap<String, Option<Instant>>>,
pub net_hosts_session_only: AtomicBool,
session_net_hosts: Mutex<HashMap<String, BTreeSet<String>>>,
pub(super) next_prompt: AtomicU64,
pub(super) run_configurations: super::run_detectors::DynamicConfigurations,
pub(super) run_targets: Mutex<super::run_targets::Targets>,
@@ -523,9 +524,6 @@ pub(super) struct Shared {
pub secrets: RwLock<Arc<dyn SecretStore>>,
/// (plugin, `process:` capability) → the approved program.
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// The grant ledger, shared with the [`super::PluginHost`]: `net:ask`
/// looks up and records "always allow" hosts right where a request runs.
pub ledger: Arc<Mutex<Ledger>>,
pub project_pins: ProjectPins,
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
/// Validated UI declarations (settings schemas, declared views).
@@ -550,7 +548,6 @@ impl Shared {
data_dir: PathBuf,
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
ledger: Arc<Mutex<Ledger>>,
) -> Arc<Self> {
Arc::new(Self {
ui,
@@ -559,7 +556,6 @@ impl Shared {
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
ledger,
project_pins: Arc::default(),
event_editors: RwLock::default(),
data_dir,
@@ -572,9 +568,6 @@ impl Shared {
process_shutdowns: Mutex::default(),
prompts: Mutex::default(),
prompts_blocked: Mutex::default(),
net_prompts: Mutex::default(),
net_hosts_session_only: AtomicBool::new(false),
session_net_hosts: Mutex::default(),
next_prompt: AtomicU64::new(1),
run_configurations: Arc::default(),
run_targets: Mutex::default(),
@@ -806,8 +799,6 @@ impl Shared {
self.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: plugin.into(), sets: Vec::new() });
}
self.withdraw_prompts(plugin);
self.net_prompts.lock().remove(plugin);
self.session_net_hosts.lock().remove(plugin);
let removed: Vec<(Option<String>, String)> = {
let mut contexts = self.contexts.lock();
let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect();
@@ -933,15 +924,6 @@ fn storage_io(e: std::io::Error) -> HostError {
HostError::Failed(format!("storage: {e}"))
}
fn replace_entry(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut tmp =
tempfile::Builder::new().prefix(".entry-").suffix(".tmp").tempfile_in(path.parent().expect("has parent"))?;
tmp.write_all(bytes)?;
tmp.persist(path).map_err(|e| e.error)?;
Ok(())
}
/// A key's file name: its hash, since keys may hold any character.
fn entry_name(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
@@ -963,7 +945,8 @@ fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(value.as_bytes());
replace_entry(&file, &bytes).map_err(storage_io)
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
}
/// The stored entries' files and the bytes of their keys and values.
@@ -1000,9 +983,6 @@ impl PluginApi {
/// older version into it.
fn storage(&self) -> Result<PathBuf, HostError> {
let dir = self.shared.storage_dir(self.id());
std::fs::create_dir_all(&self.shared.data_dir).map_err(storage_io)?;
ide_vfs::create_private_dir(dir.parent().expect("has parent")).map_err(failed)?;
ide_vfs::create_private_dir(&dir).map_err(failed)?;
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
match std::fs::read(&legacy) {
Ok(bytes) => {
@@ -2231,90 +2211,6 @@ impl HostApi for PluginApi {
})
}
/// The `net:ask` answer: a host the user allowed with "always" is
/// remembered in the ledger (no dialog again, background included); a new
/// host is asked about in `client`'s window when there is one. Dismissal
/// and refusal both deny and throttle further questions.
fn net_ask(
&self,
subject: &Subject,
client: Option<&str>,
host: &str,
cancel: &CancellationToken,
) -> Result<Option<bool>, HostError> {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if !self.pkg.manifest.declares(&Capability::NetAsk) {
return Ok(None);
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
{
let ledger = self.shared.ledger.lock();
if let Some(grant) = ledger.get(subject)
&& grant.confirmed
&& grant.has(&Capability::NetAsk)
&& grant.net_hosts.contains(&host)
{
return Ok(Some(true));
}
}
if self.shared.session_net_hosts.lock().get(self.id()).is_some_and(|hosts| hosts.contains(&host)) {
return Ok(Some(true));
}
let Some(client) = client else { return Ok(None) };
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Ok(None);
}
}
if self.shared.prompts_blocked.lock().contains(self.id()) {
return Ok(None);
}
{
let mut prompts = self.shared.net_prompts.lock();
if prompts.get(self.id()).is_some_and(|until| until.is_none_or(|until| until > Instant::now())) {
return Ok(None);
}
prompts.insert(self.id().to_owned(), None);
}
let session_only = self.shared.net_hosts_session_only.load(Ordering::Relaxed);
let answer = self.ask(Some(client), Prompt::NetPermission { host: host.clone(), session_only }, cancel);
let result = match answer {
Ok(Some(keys)) if keys == ["session"] || keys == ["always"] => {
let mut ledger = self.shared.ledger.lock();
if cancel.is_cancelled() {
Err(HostError::Cancelled)
} else if ledger.get(subject).is_some_and(|g| g.confirmed && g.has(&Capability::NetAsk)) {
if session_only || keys == ["session"] {
self.shared.session_net_hosts.lock().entry(self.id().into()).or_default().insert(host);
} else {
ledger.remember_net_host(subject, &host);
if let Err(e) = ledger.save() {
tracing::warn!("saving plugin grants: {e}");
}
}
drop(ledger);
self.shared.sink.emit(None, PluginEvent::CommandsChanged);
Ok(Some(true))
} else {
Ok(None)
}
}
Ok(Some(keys)) if keys == ["once"] => Ok(Some(false)),
Ok(_) => Ok(None),
Err(e) => Err(e),
};
let mut prompts = self.shared.net_prompts.lock();
if matches!(result, Ok(Some(_))) {
prompts.remove(self.id());
} else {
prompts.insert(self.id().to_owned(), Some(Instant::now() + Duration::from_secs(30)));
}
result
}
fn secret_is_set(&self, name: &str) -> bool {
self.shared.secrets.read().get(self.id(), name).is_some()
}
@@ -2786,9 +2682,7 @@ impl HostApi for PluginApi {
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
Err(e) => return Err(not_writable(real, e)),
};
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
check_write_path(real)?;
if private {
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
@@ -2798,9 +2692,7 @@ impl HostApi for PluginApi {
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
check_write_path(real)?;
if private {
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
@@ -2808,16 +2700,6 @@ impl HostApi for PluginApi {
}
}
/// Approved write paths are already resolved. Refuse substitutions since approval.
fn check_write_path(path: &Path) -> Result<(), HostError> {
let parent = path.parent().ok_or_else(|| HostError::Invalid("no directory to put it in".into()))?;
let checked = ide_plugin::resolve_writable_in_root(parent, &to_ipc(path)).map_err(HostError::Invalid)?;
if checked != path {
return Err(HostError::Invalid(format!("{}: file path changed before writing", path.display())));
}
Ok(())
}
/// A file that is not there, or cannot be read, as the plugin sees it.
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
match e.kind() {
@@ -3217,127 +3099,10 @@ mod tests {
)
.unwrap();
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default());
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn net_questions_are_exclusive_throttled_and_cancelled() {
let dir = tempfile::tempdir().unwrap();
let mut api = api(dir.path(), Arc::default());
api.pkg = Package::new(
ide_extension::Manifest::parse(
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n[capabilities]\nrequired = [\"net:ask\"]\n",
).unwrap(),
dir.path().to_path_buf(),
);
let subject = Subject::Installed(api.id().into());
api.shared.ledger.lock().confirm(
&ide_extension::ledger::Request {
subject: subject.clone(),
plugin_id: api.id(),
version: "1.0.0",
package_hash: "h",
required: vec![Capability::NetAsk],
optional: vec![],
},
&[],
BTreeMap::new(),
);
for answer in [Some(vec!["always".into(), "once".into()]), None] {
api.shared.net_prompts.lock().clear();
let cancel = CancellationToken::new();
std::thread::scope(|scope| {
let run = scope.spawn(|| api.net_ask(&subject, Some("main"), "::1", &cancel));
let deadline = Instant::now() + Duration::from_secs(5);
let id = loop {
if let Some(id) = api.shared.prompts.lock().keys().next().copied() {
break id;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(api.net_ask(&subject, Some("other"), "other.example", &cancel).unwrap(), None);
if let Some(answer) = answer {
assert!(api.shared.answer_prompt(id, Some(answer)));
assert_eq!(run.join().unwrap().unwrap(), None);
} else {
cancel.cancel();
assert!(matches!(run.join().unwrap(), Err(HostError::Cancelled)));
}
});
assert!(api.shared.prompts.lock().is_empty());
assert!(api.shared.ledger.lock().get(&subject).unwrap().net_hosts.is_empty());
assert_eq!(api.net_ask(&subject, Some("main"), "new.example", &CancellationToken::new()).unwrap(), None);
}
}
#[cfg(unix)]
#[test]
fn writes_recheck_approved_paths_after_symlink_substitution() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("nested")).unwrap();
let path = ide_plugin::resolve_writable_in_root(&root, "nested/file.txt").unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
symlink(&outside, &path).unwrap();
assert!(api.write_text_file(&path, "text").is_err());
assert!(api.write_bytes_file(&path, b"bytes").is_err());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(root.join("nested")).unwrap();
symlink(tmp.path(), root.join("nested")).unwrap();
assert!(api.write_text_file(&path, "text").is_err());
assert!(api.write_bytes_file(&path, b"bytes").is_err());
assert!(!tmp.path().join("file.txt").exists());
}
#[cfg(unix)]
#[test]
fn storage_and_secrets_do_not_write_through_temporary_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
let dir = api.storage().unwrap();
let entry = dir.join(entry_name("key"));
symlink(&outside, entry.with_extension("tmp")).unwrap();
symlink(&outside, &entry).unwrap();
api.storage_set("key", Some("value".into())).unwrap();
assert_eq!(api.storage_get("key").unwrap().as_deref(), Some("value"));
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
secrets.set("acme.lint", "key", Some("before")).unwrap();
symlink(&outside, secrets.file("acme.lint").with_extension("tmp")).unwrap();
secrets.set("acme.lint", "key", Some("after")).unwrap();
assert_eq!(secrets.get("acme.lint", "key").as_deref(), Some("after"));
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
}
#[cfg(unix)]
#[test]
fn storage_and_secrets_refuse_symlinked_private_directories() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let dir = api.storage().unwrap();
std::fs::remove_dir(&dir).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, &dir).unwrap();
assert!(api.storage_set("key", Some("value".into())).is_err());
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
std::fs::create_dir(&secrets.dir).unwrap();
std::os::unix::fs::symlink(&outside, secrets.dir.join("acme.lint")).unwrap();
assert!(secrets.set("acme.lint", "key", Some("value")).is_err());
assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn private_directory_matches_resolved_write_paths() {
@@ -3951,7 +3716,6 @@ mod workbench_tests {
root.join(".data"),
Arc::default(),
ide_extension::UiStore::new(),
Arc::default(),
);
*shared.workspace.write() =
Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) }));
+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());
}
}
+10 -79
View File
@@ -143,8 +143,6 @@ struct Meta {
retry_at: Option<Instant>,
/// The component exports `syntax-provider` (known once activated).
has_syntax: bool,
/// Known before activation can publish target registrations.
has_run_target_validation: bool,
/// Next `on-schedule` activation.
next_schedule: Option<Instant>,
/// The trusted publisher of a signed installed package.
@@ -166,7 +164,6 @@ impl Meta {
failures: 0,
retry_at: None,
has_syntax: false,
has_run_target_validation: false,
publisher: None,
next_schedule: None,
}
@@ -380,7 +377,7 @@ pub struct PluginHost {
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Arc<Mutex<Ledger>>,
ledger: Mutex<Ledger>,
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
state: Mutex<PersistedState>,
/// What every plugin's [`api::PluginApi`] shares: sink, registry,
@@ -452,9 +449,7 @@ impl PluginHost {
languages.set_process_policy(Arc::new(move |plugin, cap| {
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}));
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
let shared =
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
let official_keys = market::built_in_official_keys();
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
let host = Self {
@@ -465,7 +460,7 @@ impl PluginHost {
updates: Mutex::default(),
project_policy: RwLock::new(Arc::new(|_| false)),
engine: PluginEngine::new().map_err(IdeError::internal)?,
ledger,
ledger: Mutex::new(Ledger::load(&plugins_dir)),
plugins_dir,
sink,
limits,
@@ -808,11 +803,6 @@ impl PluginHost {
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
}
/// Keeps hosts allowed through `net:ask` in memory for this host's lifetime.
pub fn set_net_hosts_session_only(&self) {
self.shared.net_hosts_session_only.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// The UI language windows run in, as `locale()` reports it to plugin code.
pub fn set_ui_locale(&self, tag: &str) {
*self.shared.locale.write() = tag.to_owned();
@@ -1086,39 +1076,19 @@ impl PluginHost {
self.info_of(id)
}
/// Revokes one exact host remembered through `net:ask`.
pub fn revoke_net_host(&self, id: &str, host: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
{
let mut ledger = self.ledger.lock();
if !ledger.forget_net_host(&plugin.subject(), host) {
return Err(IdeError::invalid("host is not remembered for this plugin"));
}
self.save_ledger(&ledger);
}
self.stop(&plugin);
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
self.changed();
self.info_of(id)
}
/// Grants or revokes one declared capability (Settings › Plugins › Permissions).
/// Grants or revokes one capability (Settings › Plugins › Permissions).
/// Revoking a required one stops the plugin until consent is given again.
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
}
{
let mut ledger = self.ledger.lock();
let subject = plugin.subject();
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
}
if ledger.get(&subject).is_none() {
return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet")));
}
@@ -1462,20 +1432,6 @@ impl PluginHost {
cancel: Option<&ide_core::CancellationToken>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
self.code_call_with_budget(id, invocation, what, cancel, None, f)
}
fn code_call_with_budget<R: Send + 'static>(
&self,
id: &str,
invocation: Invocation,
what: &str,
cancel: Option<&ide_core::CancellationToken>,
budget: Option<Duration>,
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
) -> IdeResult<R> {
let deadline = budget.map(|budget| Instant::now() + budget);
let fallback = ide_core::CancellationToken::new();
let plugin = self.get(id)?;
let client = invocation.client.clone();
{
@@ -1516,25 +1472,10 @@ impl PluginHost {
}
let started = Instant::now();
let (call, inv) = (f.clone(), invocation.clone());
let result = match (deadline, cancel) {
(Some(deadline), cancel) => actor.call_bounded(
cancel.unwrap_or(&fallback),
Duration::from_millis(50),
deadline.saturating_duration_since(Instant::now()),
move |rt| call(rt, inv),
),
(None, Some(cancel)) => {
actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv))
}
(None, None) => actor.call(None, move |rt| call(rt, inv)),
let result = match cancel {
Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)),
None => actor.call(None, move |rt| call(rt, inv)),
};
if deadline.is_some() && matches!(result, Err(ActorError::SoftTimeout)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::new(
ide_core::error::ErrorKind::TimedOut,
format!("plugin {id:?} timed out while {what}"),
));
}
if matches!(result, Err(ActorError::Cancelled)) {
self.reap(&plugin, &actor, client.as_deref());
return Err(IdeError::cancelled());
@@ -2564,7 +2505,6 @@ impl PluginHost {
plugin_id: plugin.id().to_owned(),
version: plugin.manifest().version.clone(),
granted,
subject: plugin.subject(),
host: api,
limits: self.limits,
};
@@ -2583,7 +2523,6 @@ impl PluginHost {
return Err(IdeError::internal(format!("plugin {:?} {e}", plugin.id())));
}
};
plugin.meta.lock().has_run_target_validation = rt.has_run_target_validation();
let crash = match rt.activate() {
Ok(_) => None,
Err(CallError::Trap(e)) => Some(format!("activation {e}")),
@@ -2942,13 +2881,6 @@ impl PluginHost {
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
}
}
// Hosts remembered through `net:ask` are grants of their own: listed
// separately so the user can revoke them one by one.
let net_hosts = grant
.as_ref()
.filter(|g| g.confirmed && g.has(&Capability::NetAsk) && m.declares(&Capability::NetAsk))
.map(|g| g.net_hosts.iter().cloned().collect())
.unwrap_or_default();
PluginInfo {
id: m.id.clone(),
name: m.name.clone(),
@@ -2965,7 +2897,6 @@ impl PluginHost {
error: meta.error.clone(),
has_code: m.wasm.is_some(),
capabilities,
net_hosts,
contributions: self.registry.contributions(&m.id),
activation: m.activation.iter().map(ToString::to_string).collect(),
warnings: meta.warnings.clone(),
@@ -292,7 +292,6 @@ impl RemotePlugins {
}
};
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
host.set_net_hosts_session_only();
projects.set_plugin_host(&host);
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
+14 -48
View File
@@ -21,6 +21,15 @@ fn selector(plugin: &str, id: &str) -> String {
format!("plugin-target:{}:{plugin}{id}", plugin.len())
}
impl Shared {
fn targets_changed(&self) {
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
for project in host.projects() {
project.lsp.reconfigure_soon();
}
}
self.sink.emit(None, PluginEvent::RunTargetsChanged);
}
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
let key = format!("{}\0{id}", root.display());
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
@@ -31,7 +40,7 @@ impl Shared {
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
if before != targets.len() {
drop(targets);
self.sink.emit(None, PluginEvent::RunTargetsChanged);
self.targets_changed();
}
}
}
@@ -70,12 +79,12 @@ impl PluginApi {
);
drop(targets);
drop(blocked);
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
self.shared.targets_changed();
Ok(())
}
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
self.shared.targets_changed();
}
}
}
@@ -87,14 +96,11 @@ impl PluginHost {
pub fn run_targets(&self, root: &Path) -> Vec<RegisteredRunTarget> {
self.shared.run_targets.lock().iter().filter(|((r, _), _)| r == root).map(|(_, t)| t.clone()).collect()
}
/// Prepares a user launch, including the optional plugin validation gate.
pub fn prepare_run_target_launch(
pub fn prepare_run_target(
&self,
root: &Path,
id: &str,
config: &ide_run::RunConfig,
operation: ide_plugin::RunTargetOperation,
cancel: &ide_core::CancellationToken,
) -> IdeResult<ide_run::RunConfig> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
@@ -107,47 +113,7 @@ impl PluginHost {
.cloned()
.ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
let program = self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
let wrapped = ide_remote::plugin_target::prepare(root, &target.target, &program, config)?;
if self.get(&target.plugin_id)?.meta.lock().has_run_target_validation {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let info = ide_plugin::RunTargetLaunch {
operation,
program: ide_run::wrap::program_name(&config.command),
cwd: wrapped.cwd.as_ref().unwrap().to_string_lossy().into_owned(),
};
let project_root = root.to_path_buf();
let invocation =
ide_plugin::Invocation { workspace_root: Some(project_root.clone()), ..Default::default() };
let (target_id, timeout) = (target.target.id.clone(), self.limits.timeout);
let result = self.code_call_with_budget(
&target.plugin_id,
invocation,
"validating the run target",
Some(cancel),
Some(timeout),
move |rt, _| {
rt.validate_run_target(
project_root.to_string_lossy().into_owned(),
target_id.clone(),
info.clone(),
timeout,
)
},
);
match result {
Ok(Ok(())) => {}
Ok(Err(message)) => return Err(IdeError::invalid(format!("{}: {message}", target.plugin_name))),
Err(error) if error.kind == ide_core::error::ErrorKind::Cancelled => return Err(error),
Err(error) => return Err(IdeError::unavailable(format!("{}: {error}", target.plugin_name))),
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
self.program_for_project(&target.plugin_id, &target.target.program, Some(root))?;
}
Ok(wrapped)
ide_remote::plugin_target::prepare(root, &target.target, &program, config)
}
}
-3
View File
@@ -72,9 +72,6 @@ pub struct PluginInfo {
pub has_code: bool,
/// Declared capabilities with risk and grant state.
pub capabilities: Vec<CapabilityInfo>,
/// HTTPS hosts the user allowed through `net:ask` ("always allow"),
/// none of them declared by the manifest; each can be revoked.
pub net_hosts: Vec<String>,
/// What it contributes while live (point + label).
pub contributions: Vec<ContributionInfo>,
/// `activation` events of the manifest.
+57 -5
View File
@@ -139,11 +139,49 @@ impl Host {
let settings = Arc::new(settings);
{
let settings = settings.clone();
// Workspace members have independent settings.
// Workspace members have independent settings; target servers have their own path mappings.
lsp.set_jdtls_shared_hook(Box::new(move || {
!is_workspace && settings.effective().languages.java.jdtls_shared
let effective = settings.effective();
!is_workspace
&& effective.languages.java.jdtls_shared
&& effective.language_server_targets.get("java").is_none_or(|target| target.target.is_empty())
}));
}
{
let settings = settings.clone();
let root = root.clone();
let plugins = self.plugin_host.clone();
let reader_plugins = plugins.clone();
lsp.set_target_hooks(
Box::new(move |language, cfg| {
let effective = settings.effective();
let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
else {
return false;
};
let Some(plugins) = plugins.read().upgrade() else { return false };
// A withdrawn target returns the server to the host. An invalid active
// target stays visibly failed; it must never silently fall back.
if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) {
return false;
}
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) {
cfg.launch_error = Some(e.to_string());
}
true
}),
Box::new(move |target, path| {
let plugins = reader_plugins
.read()
.upgrade()
.ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?;
crate::ops::lsp_target::read(&plugins, target, path)
}),
);
}
{
let settings = settings.clone();
let root = root.clone();
@@ -281,7 +319,23 @@ impl Host {
{
let settings = settings.clone();
lsp.set_servers_hook(Box::new(move |language, defined| {
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
let effective = settings.effective();
let mut servers = ide_sdk::apply::language_servers(language.as_str(), defined, &effective);
if let Some(selection) =
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
{
let chosen = if selection.server.is_empty() {
servers.first().map(|s| s.name.clone())
} else {
Some(selection.server.clone())
};
servers.retain(|s| Some(&s.name) == chosen.as_ref());
for server in &mut servers {
server.fallback = false;
server.target_key = Some(selection.target.clone());
}
}
servers
}));
}
{
@@ -358,7 +412,6 @@ impl Host {
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.ignore_rules_changed(&batch.ignore_rules);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
phpstan.files_removed(batch.removed.iter());
@@ -1256,7 +1309,6 @@ fn content_root_watcher(
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.ignore_rules_changed(&batch.ignore_rules);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
// A workspace member's PHP language level follows its own composer.json.
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
+1 -1
View File
@@ -262,7 +262,7 @@ impl Dispatcher {
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
let on_output = c.channel("onOutput")?;
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await
})
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
-10
View File
@@ -247,7 +247,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "clipboard_has_files"
| "clipboard_image"
| "clipboard_set_file_list"
| "clipboard_write_text"
| "jdk_download_vendors"
| "jdk_download_releases"
| "jdk_download_location"
@@ -420,7 +419,6 @@ pub fn projectless_command_allowed(cmd: &str) -> bool {
| "plugin_set_enabled"
| "plugin_grant"
| "plugin_set_capability"
| "plugin_revoke_net_host"
| "plugin_choose_program"
| "plugin_program"
| "plugin_roll_back"
@@ -456,11 +454,3 @@ impl TrustStore {
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
}
}
#[cfg(test)]
mod tests {
#[test]
fn restricted_mode_allows_clipboard_write_text() {
assert!(super::restricted_command_allowed("clipboard_write_text"));
}
}
+53 -107
View File
@@ -59,15 +59,16 @@ struct Checks {
hover: (At, &'static str),
/// Completion at the end of the typed line offers this item.
completion: (Typed, &'static str),
/// The typed line is reported with a diagnostic, and its quick fix applies.
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
/// file contain the last text.
quick_fix: QuickFix,
/// With the word selected, this intention is offered with an edit or command.
intention: Option<(At, &'static str)>,
intention: (At, &'static str),
/// Formatting turns the typed line into the expected one.
format: (Typed, &'static str),
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: Option<(At, &'static str, &'static [&'static str])>,
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
@@ -103,8 +104,8 @@ enum Imports {
struct QuickFix {
typed: Typed,
diagnostic: &'static str,
/// The quick fix with this title makes the file contain this text.
fix: Option<(&'static str, &'static str)>,
title: &'static str,
result: &'static str,
}
/// The editor's indentation, sent with a format request.
@@ -134,7 +135,7 @@ fn copy_dir(from: &Path, to: &Path) {
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv" | "vendor")) {
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
continue;
}
let target = to.join(&name);
@@ -391,21 +392,19 @@ impl Smoke {
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
}
async fn quick_fix(&self, q: QuickFix) {
let (doc, _, before) = self.type_line(q.typed).await;
let range = self.diagnostic(q.typed.file, q.diagnostic).await;
if let Some((title, result)) = q.fix {
assert!(!before.contains(result), "{q:?}: the file already contains the result");
let action = self.action("quick fix", doc, range, title).await;
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
.await
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {title:?}: {e}")));
self.retry("quick fix", Duration::from_secs(10), || async {
let text = self.doc(q.typed.file).await.1;
if text.contains(result) { Ok(()) } else { Err(text) }
})
.await;
}
async fn quick_fix(&self, fix: QuickFix) {
let (doc, _, before) = self.type_line(fix.typed).await;
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
let action = self.action("quick fix", doc, range, fix.title).await;
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
.await
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
self.retry("quick fix", Duration::from_secs(10), || async {
let text = self.doc(fix.typed.file).await.1;
if text.contains(fix.result) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
@@ -620,9 +619,7 @@ impl Smoke {
self.hover(c.hover.0, c.hover.1).await;
self.completion(c.completion.0, c.completion.1).await;
self.quick_fix(c.quick_fix).await;
if let Some((on, title)) = c.intention {
self.intention(on, title).await;
}
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
@@ -630,9 +627,7 @@ impl Smoke {
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
if let Some((symbol, to, files)) = c.rename {
self.rename(symbol, to, files).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
@@ -661,20 +656,21 @@ const TYPESCRIPT: Checks = Checks {
quick_fix: QuickFix {
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
diagnostic: "Cannot find name 'addMoney'",
fix: Some(("import from \"./money.ts\"", "import { addMoney, cents, formatMoney } from \"./money.ts\";")),
title: "import from \"./money.ts\"",
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
},
intention: Some((at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant")),
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
format: (
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
" const shown = { total, count: cart.count }",
),
indent: Indent::Spaces(2),
rename: Some((
rename: (
// From a usage in another file: the declaration is renamed too, not aliased in the import.
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
"formatPrice",
&["src/index.ts", "src/money.ts"],
)),
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
@@ -717,20 +713,21 @@ const PYTHON: Checks = Checks {
quick_fix: QuickFix {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
diagnostic: "\"Task\" is not defined",
fix: Some(("from .models import Task", "from .models import Priority, Task, TaskList")),
title: "from .models import Task",
result: "from .models import Priority, Task, TaskList",
},
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
intention: Some((at("src/tasks/models.py", "import os", "os"), "Remove unused import")),
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
format: (
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
" shown2 = {\"a\": 1, \"b\": 2}",
),
indent: Indent::Spaces(4),
rename: Some((
rename: (
at("src/tasks/models.py", "class TaskList:", "TaskList"),
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
)),
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
@@ -771,22 +768,16 @@ const RUST: Checks = Checks {
" let _blank = parser::ParseError { line: 1 };",
),
diagnostic: "missing structure fields",
fix: Some(("Fill struct fields", "ParseError { line: 1, message")),
title: "Fill struct fields",
result: "ParseError { line: 1, message",
},
intention: Some((
at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"),
"Extract into variable",
)),
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
format: (
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
" let _spaced = (1, 2);",
),
indent: Indent::Spaces(4),
rename: Some((
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
"parse_all",
&["src/main.rs", "src/parser.rs"],
)),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
@@ -825,16 +816,17 @@ const GO: Checks = Checks {
quick_fix: QuickFix {
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
diagnostic: "undefined: fmt",
fix: Some(("\"fmt\"", "\t\"fmt\"")),
title: "\"fmt\"",
result: "\t\"fmt\"",
},
intention: Some((at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract")),
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
indent: Indent::Tabs,
rename: Some((
rename: (
at("internal/store/store.go", "func New() *Store", "New"),
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
)),
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
@@ -880,12 +872,13 @@ const JAVA: Checks = Checks {
" List<Order> all = book.byCustomer(\"alice\");",
),
diagnostic: "List cannot be resolved to a type",
fix: Some(("Import 'List' (java.util)", "import java.util.List;")),
title: "Import 'List' (java.util)",
result: "import java.util.List;",
},
intention: Some((
intention: (
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
"Extract to local variable",
)),
),
format: (
typed(
"src/main/java/com/example/orders/App.java",
@@ -895,11 +888,11 @@ const JAVA: Checks = Checks {
" long doubled = revenue * 2;",
),
indent: Indent::Spaces(4),
rename: Some((
rename: (
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
)),
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
@@ -966,22 +959,23 @@ const KOTLIN: Checks = Checks {
" val stamp = LocalDateTime.now()",
),
diagnostic: "Unresolved reference",
fix: Some(("Import", "import java.time.LocalDateTime")),
title: "Import",
result: "import java.time.LocalDateTime",
},
intention: Some((
intention: (
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
"block body",
)),
),
format: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
" val pair = listOf(1, 2)",
),
indent: Indent::Spaces(4),
rename: Some((
rename: (
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
)),
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
@@ -1009,51 +1003,3 @@ const KOTLIN: Checks = Checks {
async fn kotlin() {
smoke("kotlin", KOTLIN).await;
}
const PHP: Checks = Checks {
fixture: "php-app",
files: &[],
warm_up: Duration::from_secs(60),
definition: (
at("bin/quote.php", "$customs = new ThresholdCustoms", "ThresholdCustoms"),
at("src/Shipping/ThresholdCustoms.php", "final class ThresholdCustoms", "ThresholdCustoms"),
),
references: (
at("src/Shipping/Parcel.php", "public function billableGrams", "billableGrams"),
&["bin/quote.php", "src/Shipping/Parcel.php", "src/Shipping/Shipment.php", "src/Shipping/Tariff.php"],
),
hover: (
at("bin/quote.php", "$parcel->billableGrams($shipment->route)", "billableGrams"),
"public function billableGrams(Route $route): int",
),
completion: (typed("bin/quote.php", "$freightCents += $cents;", " $parcel->"), "billableGrams"),
// Intelephense declares no code actions and no rename without a licence key.
quick_fix: QuickFix {
typed: typed("bin/quote.php", "$freightCents += $cents;", " $tariff->weigh($parcel);"),
diagnostic: "Undefined method 'weigh'",
fix: None,
},
intention: None,
format: (
typed("bin/quote.php", "$freightCents += $cents;", " $pair=[1,2 ,3];"),
" $pair = [1, 2, 3];",
),
indent: Indent::Spaces(4),
rename: None,
signature: (typed("bin/quote.php", "$freightCents += $cents;", " $tariff->freightCents("), "parcel"),
outline: ("src/Shipping/Shipment.php", &["Shipment", "add", "heaviest", "byStore"]),
auto_import: AutoImport {
typed: typed("bin/quote.php", "$freightCents += $cents;", " $status = ParcelSta"),
label: "ParcelStatus",
result: "use App\\Shipping\\ParcelStatus;",
},
organize_imports: None,
// Intelephense has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs intelephense"]
async fn php() {
smoke("php", PHP).await;
}
+1 -193
View File
@@ -250,7 +250,7 @@ async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
f.host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
let tool = f.root.with_file_name("wrapper");
let tool = f._tmp.path().join("wrapper");
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let invocation =
@@ -352,195 +352,3 @@ async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
assert!(plugins.run_targets(&f.root).is_empty());
plugins.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn run_target_api15_overrides_and_frozen_api14_registration() {
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits, PluginEngine};
use std::os::unix::fs::PermissionsExt;
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(f._tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(),
);
plugins.set_workspace(Some(Arc::new(f.workspace())));
f.host.set_plugin_host(&plugins);
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures");
let engine = PluginEngine::new().unwrap();
let legacy = engine.compile(&fixtures.join("run-target-api14/dist/plugin.wasm")).unwrap();
assert_eq!(engine.check_component(&legacy).unwrap().unwrap().to_string(), "1.4.0");
let tool = f.root.with_file_name("wrapper");
std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"],
"env":{}, "detected":false, "isTest":false,
}))
.unwrap();
for (plugin, command) in [("run-target-api14", "target"), ("execution", "target-overrides")] {
plugins.install_from_dir(&fixtures.join(plugin), &Consent::all()).unwrap();
assert!(plugins.run_command(plugin, command, Some("empty"), invocation()).is_err());
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
let cases = if plugin == "execution" {
vec![
("empty", vec!["composer", "install"]),
("own", vec!["composer", "--working-dir=/container", "install"]),
("inherit", vec!["composer", "--cwd=/container", "install"]),
]
} else {
vec![("inherit", vec!["composer", "--cwd=/container", "install"])]
};
for (arg, expected) in cases {
plugins.run_command(plugin, command, Some(arg), invocation()).unwrap();
let target = plugins.run_targets(&f.root).into_iter().find(|t| t.plugin_id == plugin).unwrap();
let wrapped = plugins
.prepare_run_target_launch(
&f.root,
&target.id,
&config,
ide_plugin::RunTargetOperation::Run,
&Default::default(),
)
.unwrap();
assert_eq!(wrapped.args, expected);
assert_eq!(PathBuf::from(wrapped.command), tool);
assert_eq!(wrapped.cwd.unwrap(), f.root);
let php = ide_run::RunConfig { command: "php".into(), args: vec!["x.php".into()], ..config.clone() };
assert_eq!(
plugins
.prepare_run_target_launch(
&f.root,
&target.id,
&php,
ide_plugin::RunTargetOperation::Run,
&Default::default()
)
.unwrap()
.args,
["wrapped", "--cwd=/container", "php", "x.php"]
);
}
if plugin == "execution" {
assert!(plugins.run_command(plugin, command, Some("invalid"), invocation()).is_err());
// A failed replacement retains the previous valid registration.
assert_eq!(plugins.run_targets(&f.root).iter().filter(|t| t.plugin_id == plugin).count(), 1);
}
plugins.set_capability(plugin, "process:execution-tool", false).unwrap();
assert!(plugins.run_targets(&f.root).iter().all(|t| t.plugin_id != plugin));
assert!(plugins.run_command(plugin, command, None, invocation()).is_err());
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
f.host.set_project_trust(&f.root, false, false).await.unwrap();
assert!(plugins.run_command(plugin, command, None, invocation()).unwrap_err().to_string().contains("trusted"));
f.host.set_project_trust(&f.root, true, false).await.unwrap();
}
plugins.shutdown();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn run_target_validation_gates_launches_and_preserves_selection() {
use ide_host::plugins::{Consent, PluginHost};
use ide_plugin::{Invocation, Limits, RunTargetOperation};
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
let f = Harness::new();
f.host.set_project_trust(&f.root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(
f._tmp.path().join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits {
timeout: Duration::from_millis(200),
idle_timeout: Some(Duration::from_millis(20)),
restart_backoff: Duration::ZERO,
..Default::default()
},
)
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(f.workspace())));
f.host.set_plugin_host(&plugins);
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
let tool = f.root.with_file_name("wrapper");
std::fs::write(&tool, "#!/bin/sh\ntouch spawned\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
"id":"composer", "name":"Composer", "kind":"shell", "command":"/tools/composer", "args":["install"],
"env":{"SECRET":"not sent"}, "detected":false, "isTest":false,
}))
.unwrap();
for id in ["allow", "refuse", "long-refusal", "timeout", "trap"] {
plugins.run_command("execution", "target", Some(id), invocation()).unwrap();
}
let targets = plugins.run_targets(&f.root);
let id = |name: &str| targets.iter().find(|t| t.target.id == name).unwrap().id.clone();
for operation in [RunTargetOperation::Run, RunTargetOperation::Test] {
plugins.prepare_run_target_launch(&f.root, &id("allow"), &config, operation, &Default::default()).unwrap();
let snapshot = plugins.run_command("execution", "validation", None, invocation()).unwrap().unwrap();
assert_eq!(snapshot, format!("{}|allow|{operation:?}|composer|{}", f.root.display(), f.root.display()));
}
tokio::time::sleep(Duration::from_millis(30)).await;
plugins.tick();
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
plugins
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
.unwrap();
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
let selected = id("refuse");
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
for is_test in [false, true] {
let mut config = config.clone();
config.is_test = is_test;
config.extras.target = Some(selected.clone());
let error = ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.unwrap_err();
assert!(error.message.contains("Plugin execution tests: Project runtime is stopped"), "{error}");
assert!(f.host.processes.list().is_empty());
assert!(!f.root.join("spawned").exists());
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
}
let error = plugins
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
.unwrap_err();
assert!(error.message.len() < 4200);
let request = ide_core::RequestId(2408);
let mut cfg = config.clone();
cfg.extras.target = Some(id("timeout"));
let worker = f.host.clone();
let pending = tokio::spawn(async move {
ide_host::ops::run::start_request(&worker, "main", cfg, Arc::new(|_| {}), Some(request)).await
});
tokio::time::sleep(Duration::from_millis(30)).await;
f.host.cancel_request(request);
assert_eq!(pending.await.unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
for name in ["timeout", "trap"] {
let started = std::time::Instant::now();
let error = plugins
.prepare_run_target_launch(&f.root, &id(name), &config, RunTargetOperation::Run, &Default::default())
.unwrap_err();
assert!(error.message.contains("Plugin execution tests"), "{error}");
if name == "timeout" {
assert!(error.message.contains("timed out"), "{error}");
assert!(started.elapsed() < Duration::from_secs(2), "background tasks must not extend validation");
}
assert!(f.host.processes.list().is_empty());
}
// A cancelled launch never enters the wrapper.
let cancel = ide_core::CancellationToken::new();
cancel.cancel();
assert_eq!(
plugins
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &cancel)
.unwrap_err()
.kind,
ide_core::error::ErrorKind::Cancelled
);
plugins.shutdown();
}
+26 -188
View File
@@ -542,30 +542,6 @@ fn project_write_stays_inside_the_project() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[cfg(unix)]
#[test]
fn project_writes_confine_symlink_targets() {
use std::os::unix::fs::symlink;
let f = fixture(Limits::default());
f.install(&misbehave_with(f._tmp.path(), "misbehave-symlink-writer", "project.write"));
let outside = f._tmp.path().join("secret.txt");
let missing = f._tmp.path().join("missing.txt");
symlink(&outside, f.project.join("outside.txt")).unwrap();
symlink(&missing, f.project.join("dangling.txt")).unwrap();
for name in ["outside.txt", "dangling.txt"] {
let error = f.run("misbehave", "projwrite", Some(name)).unwrap_err();
assert!(error.contains("access outside the project root is not allowed"), "{error}");
}
assert_eq!(std::fs::read_to_string(outside).unwrap(), "top secret");
assert!(!missing.exists());
for (name, target) in [("inside.txt", "src/notes.txt"), ("new.txt", "new/deep.txt")] {
symlink(target, f.project.join(name)).unwrap();
f.run("misbehave", "projwrite", Some(name)).unwrap();
assert_eq!(std::fs::read_to_string(f.project.join(target)).unwrap(), "written inside the project\n");
assert!(std::fs::symlink_metadata(f.project.join(name)).unwrap().is_symlink());
}
}
/// `fs.write` replaces files the grant covers and nothing else, through the
/// real component.
#[test]
@@ -3718,6 +3694,31 @@ fn listing_while_projects_change_does_not_deadlock() {
}
}
/// Excalidraw: a web custom editor beside the text editor, with its fonts,
/// file icon, template and language — no code.
#[test]
fn official_excalidraw_installs_and_serves_its_page() {
let tmp = tempfile::tempdir().unwrap();
let pkg = with_stand_in_page(&official("excalidraw"), tmp.path());
let f = fixture(Limits::default());
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap();
let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap();
let html = String::from_utf8(html.bytes).unwrap();
// Relative URLs only: the page is served from the package.
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
assert!(script.starts_with("./assets/"), "{script}");
assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok());
let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
assert_eq!(font.mime, "font/woff2");
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw"));
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
}
/// A copy of the official plugin at `src` in `dir`, with a stand-in for its
/// built page (`web/` is built by plugins/build.sh, not checked in; build.sh
/// checks what the build produces): an entry loading a script and a font by
@@ -3759,18 +3760,7 @@ fn official_graphviz_turns_its_pages_reports_into_problems() {
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "split");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "dot"));
assert_eq!(f.host.assets("openidea.graphviz", "fileIcon/graphviz/").unwrap().len(), 2);
let page = f.host.web_page("openidea.graphviz", "preview").unwrap();
let (rev, entry) = page.path.strip_prefix("openidea.graphviz/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let html = f.host.web_file("openidea.graphviz", &format!("{rev}/{entry}")).unwrap();
let html = String::from_utf8(html.bytes).unwrap();
// Relative URLs only: the page is served from the package.
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
assert!(script.starts_with("./assets/"), "{script}");
assert!(f.host.web_file("openidea.graphviz", &format!("{rev}/web/{}", &script[2..])).is_ok());
let font = f.host.web_file("openidea.graphviz", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
assert_eq!(font.mime, "font/woff2");
assert!(f.host.web_page("openidea.graphviz", "preview").is_ok());
std::fs::write(f.project.join("flow.dot"), "digraph {\n a -> \n}\n").unwrap();
let editor = f.workspace.open("flow.dot", &[]);
@@ -5753,158 +5743,6 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
assert_eq!(f.host.declared_secret("api14", "token"), None);
}
/// `net:ask`: a first request to a host outside the manifest asks the user
/// from an invoking window ("once" allows this request, "always" remembers
/// the host, refusal and dismissal deny). Background calls are denied without
/// a dialog, plain HTTP still needs `net.http:<host>`, and a remembered host
/// is listed in the permissions, where it can be revoked.
#[test]
fn net_ask_asks_once_remembers_always_and_revokes() {
use ide_plugin::Prompt;
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap();
let (port, seen) = http_server("relay ok", |_| Duration::ZERO);
let url = format!("http://127.0.0.1:{port}/");
// Merely declaring an optional capability is not enough: the user must grant it.
let err = f.run("api14", "fetch", Some(&url)).unwrap_err();
assert!(err.contains("not granted"), "{err}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
f.host.set_capability("api14", "net:ask", true).unwrap();
// Plain HTTP elsewhere stays out of the ask flow entirely.
let err = f.run("api14", "fetch", Some("http://example.com/")).unwrap_err();
assert!(err.contains("net.http:example.com") && err.contains("not granted"), "{err}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
// Background (no invoking window): denied like before, no dialog.
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
assert!(err.message.contains("net:127.0.0.1") && err.message.contains("not granted"), "{err:?}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert!(seen.lock().is_empty(), "nothing connected");
// From a window: the question names the full host. "Allow once" lets
// this request through and remembers nothing.
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["once"]));
assert_eq!(result.unwrap().as_deref(), Some("relay ok"));
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::<String>::new());
assert_eq!(seen.lock().len(), 1);
// The next request asks again; "Always allow" remembers the host.
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["always"]));
assert_eq!(result.unwrap().as_deref(), Some("relay ok"));
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, ["127.0.0.1".to_owned()]);
// Remembered: connecting without a dialog from a window and in the background.
let result = f.host.run_command("api14", "fetch", Some(&url), f.inv()).map_err(|e| e.message).unwrap();
assert_eq!(result.as_deref(), Some("relay ok"));
let result =
f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).map_err(|e| e.message).unwrap();
assert_eq!(result.as_deref(), Some("relay ok"));
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert_eq!(seen.lock().len(), 4);
// The permissions list the host; revoking it asks again, and refusing denies.
f.host.revoke_net_host("api14", "127.0.0.1").unwrap();
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::<String>::new());
let (result, prompt) = net_ask_run_and_answer(&f, &url, None);
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
let err = result.unwrap_err();
assert!(err.contains("not granted"), "{err}");
// Refusal throttles even a different host, without making any connection.
let err = f.run("api14", "fetch", Some("https://other.example/")).unwrap_err();
assert!(err.contains("not granted"), "{err}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert_eq!(seen.lock().len(), 4);
f.host.uninstall("api14").unwrap();
let ledger = ide_extension::ledger::Ledger::load(&f.plugins_dir);
assert!(ledger.get(&ide_extension::ledger::Subject::Installed("api14".into())).is_none());
}
#[test]
fn net_ask_session_hosts_are_ephemeral_and_cleared_with_plugin_grants() {
use ide_extension::ledger::{Ledger, Subject};
use ide_plugin::Prompt;
let f = fixture(Limits::default());
f.host.set_net_hosts_session_only();
f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap();
let (port, seen) = http_server("session ok", |_| Duration::ZERO);
let url = format!("http://127.0.0.1:{port}/");
let subject = Subject::Installed("api14".into());
for (answer, reset) in [("session", "disable"), ("always", "revoke"), ("session", "uninstall")] {
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
assert!(err.message.contains("not granted"), "{err:?}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&[answer]));
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: true });
assert_eq!(result.unwrap().as_deref(), Some("session ok"));
for invocation in [f.inv(), Invocation::default()] {
let result = f.host.run_command("api14", "fetch", Some(&url), invocation).unwrap();
assert_eq!(result.as_deref(), Some("session ok"));
}
let err =
f.host.run_command("api14", "fetch", Some("https://other.example/"), Invocation::default()).unwrap_err();
assert!(err.message.contains("not granted"), "{err:?}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert!(f.host.info_of("api14").unwrap().net_hosts.is_empty());
assert!(Ledger::load(&f.plugins_dir).get(&subject).unwrap().net_hosts.is_empty());
match reset {
"disable" => {
f.host.set_enabled("api14", false).unwrap();
f.host.set_enabled("api14", true).unwrap();
}
"revoke" => {
f.host.set_capability("api14", "net:ask", false).unwrap();
f.host.set_capability("api14", "net:ask", true).unwrap();
}
"uninstall" => {
f.host.uninstall("api14").unwrap();
assert!(Ledger::load(&f.plugins_dir).get(&subject).is_none());
f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap();
}
_ => unreachable!(),
}
}
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
assert!(err.message.contains("not granted"), "{err:?}");
assert_eq!(seen.lock().len(), 9);
}
/// Runs api14's `fetch` in its window; once the `net:ask` question arrives,
/// answers it (`None`: dismisses). Returns the command's result and the prompt.
fn net_ask_run_and_answer(
f: &Fixture,
url: &str,
answer: Option<&[&str]>,
) -> (Result<Option<String>, String>, ide_plugin::Prompt) {
std::thread::scope(|scope| {
let running = scope.spawn(|| f.run("api14", "fetch", Some(url)));
let deadline = Instant::now() + Duration::from_secs(10);
let (client, prompt_id, prompt) = loop {
let asked = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
});
if let Some(asked) = asked {
break asked;
}
assert!(Instant::now() < deadline && !running.is_finished(), "the fetch never asked");
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command");
let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect());
assert!(f.host.answer_prompt(&prompt_id, answer));
assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once");
(running.join().unwrap(), prompt)
})
}
#[test]
fn api14_window_status_overrides_fall_back_and_are_withdrawn() {
let f = fixture(Limits::default());
+6 -40
View File
@@ -548,7 +548,7 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
let executable = bin.join("execution-tool");
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nif [ \"$1\" = composer ]; then printf '<%s>\\n' \"$@\" > \"$PWD/composer.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = {
let bin = bin.clone();
@@ -599,33 +599,21 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
)
.await
.unwrap();
invoke(
&peer,
"plugin_run_command",
json!({"pluginId":"execution", "commandId":"target-overrides", "arg":"empty", "editor":null}),
)
.await
.unwrap();
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
assert_eq!(targets.as_array().unwrap().len(), 1);
assert_eq!(targets[0]["pluginId"], "execution");
assert_eq!(targets[0]["target"]["commands"][0]["workdirArgs"], json!([]));
assert!(desktop.run_targets(&project).is_empty());
let id = targets[0]["id"].as_str().unwrap();
let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap();
assert_eq!(settings["run"]["defaultTarget"], "");
let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id});
invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap();
let wrapped = "<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n";
until("remote wrapped run", 5, || std::fs::read_to_string(project.join("wrapped.txt")).is_ok_and(|s| s == wrapped))
.await;
until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await;
assert_eq!(
std::fs::read_to_string(project.join("wrapped.txt")).unwrap(),
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
);
assert!(!project.join("bad").exists());
let composer = json!({"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"], "env":{}, "detected":false, "isTest":false, "target":id});
invoke(&peer, "run_start", json!({"config":composer, "onOutput":{"$channel":39}})).await.unwrap();
until("remote composer override", 5, || {
std::fs::read_to_string(project.join("composer.txt")).is_ok_and(|s| s == "<composer>\n<install>\n")
})
.await;
invoke(
&peer,
"plugin_run_command",
@@ -634,28 +622,6 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
.await
.unwrap();
assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([]));
invoke(
&peer,
"plugin_run_command",
json!({"pluginId":"execution", "commandId":"target", "arg":"refuse", "editor":null}),
)
.await
.unwrap();
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
let refused = targets[0]["id"].as_str().unwrap();
std::fs::remove_file(project.join("wrapped.txt")).unwrap();
let config = json!({"id":"refused", "name":"Refused", "kind":"shell", "command":"php", "args":[], "env":{}, "detected":false, "isTest":false, "target":refused});
let error = invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":40}})).await.unwrap_err();
assert!(error.to_string().contains("Project runtime is stopped"), "{error}");
assert!(!project.join("wrapped.txt").exists());
assert!(desktop.run_targets(&project).is_empty());
invoke(
&peer,
"plugin_run_command",
json!({"pluginId":"execution", "commandId":"untarget", "arg":"refuse", "editor":null}),
)
.await
.unwrap();
client.plugin_events.lock().clear();
client.events.lock().clear();
let runner = peer.clone();
+102
View File
@@ -0,0 +1,102 @@
//! Target debugging uses the same trusted plugin wrapper on desktop and the SSH server.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::LanguageRegistry;
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
#[tokio::test(flavor = "multi_thread")]
async fn registered_target_debugs_over_stdio_and_rechecks_program_grant() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let adapter = base.join("adapter.py");
std::fs::write(&adapter, include_str!("../../ide-dap/tests/fixtures/target_adapter.py")).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then exec cat "$2" "$3"; fi
export FAKE_ROOT=/container
exec /usr/bin/python3 -u '{}'
"#,
adapter.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let launch: ide_dap::LaunchConfig=serde_json::from_value(json!({"name":"Fake target","adapter":"debugpy","request":"launch","program":root.join("main.py"),"args":[],"cwd":root,"env":{},"extraJson":json!({"__runTarget":id}).to_string()})).unwrap();
host.debugger
.set_breakpoints(
root.join("main.py"),
vec![ide_dap::Breakpoint {
line: 2,
enabled: true,
condition: None,
hit_condition: None,
log_message: None,
}],
)
.await
.unwrap();
let session = ide_host::ops::debug::debug_start(&host, "main", launch.clone()).await.unwrap();
let frames = host.debugger.stack_trace(session, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
host.debugger.stop(session).await.unwrap();
// External debug sources use the container reader even with no LSP running.
let library = base.join("library.py");
std::fs::write(&library, "container library").unwrap();
let uri = format!(
"openidea-target://{}/0/{}",
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC),
library.to_string_lossy().trim_start_matches('/')
);
let doc = ide_host::ops::doc::doc_open_virtual(&host, "main", uri).await.unwrap();
assert!(doc.read_only);
assert_eq!(doc.text, "container library");
std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap();
let error = ide_host::ops::debug::debug_start(&host, "main", launch).await.unwrap_err();
assert!(error.message.contains("changed") || error.message.contains("approved"), "{error:?}");
host.close_project("main");
}
+224
View File
@@ -0,0 +1,224 @@
//! Real stdio LSP through a registered plugin wrapper, without a container daemon.
#![cfg(unix)]
use ide_core::{DocId, IdeEvent, LanguageId, Pos};
use ide_host::{
Host, HostConfig,
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
};
use ide_language::{LanguageConfig, LanguageRegistry};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde_json::json;
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc, time::Duration};
const SERVER: &str = r#"import sys,json,os
root = 'file:///container' if os.environ.get('FAKE_IN_TARGET') else sys.argv[3]
def send(v):
body=json.dumps(v).encode(); sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n'%len(body)).encode()+body); sys.stdout.buffer.flush()
while True:
headers={}
while True:
line=sys.stdin.buffer.readline()
if not line: sys.exit(0)
if line in (b'\r\n',b'\n'): break
k,v=line.decode().split(':',1); headers[k.lower()]=v.strip()
msg=json.loads(sys.stdin.buffer.read(int(headers['content-length'])))
method=msg.get('method'); params=msg.get('params',{})
if method=='initialize':
assert params['rootUri']==root,params
assert params['rootPath']==root.removeprefix('file://'),params
assert params['workspaceFolders'][0]['uri']==root,params
open(sys.argv[1],'a').write(json.dumps(params)+'\n')
result={'capabilities':{'textDocumentSync':1,'definitionProvider':True,'documentLinkProvider':True}}
elif method=='textDocument/didOpen':
if params['textDocument']['uri'] != root+'/main.mock': continue
send({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':root+'/main.mock','diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'message':'mapped diagnostic','severity':2}]}})
continue
elif method=='textDocument/documentLink':
result=[{'target':sys.argv[2]+'#L2','range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}}}]
elif method=='textDocument/definition':
assert params['textDocument']['uri']==root+'/main.mock',params
r={'start':{'line':0,'character':0},'end':{'line':0,'character':1}}
result=[{'uri':root+'/definition.mock','range':r},{'uri':sys.argv[2],'range':r}]
elif method=='shutdown': result=None
elif method=='exit': sys.exit(0)
else:
if 'id' not in msg: continue
result=None
send({'jsonrpc':'2.0','id':msg['id'],'result':result})
"#;
#[tokio::test(flavor = "multi_thread")]
async fn initialize_definition_diagnostics_external_read_only_and_withdrawal() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let source = root.join("main.mock");
std::fs::write(&source, "x").unwrap();
std::fs::write(root.join("definition.mock"), "x").unwrap();
let library = base.join("library with space.mock");
std::fs::write(&library, "read only library").unwrap();
let log = base.join("initialize.json");
let reads = base.join("reads");
let server = base.join("server.py");
std::fs::write(&server, SERVER).unwrap();
let wrapper = base.join("wrapper");
std::fs::write(
&wrapper,
format!(
r#"#!/bin/sh
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
shift 2
if [ "$1" = cat ]; then printf '%s' "$3" > '{}'; fi
export FAKE_IN_TARGET=1
exec "$@"
"#,
reads.display()
),
)
.unwrap();
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut language: LanguageConfig = toml::from_str(
"id='mock'\nname='Mock'\nextensions=['mock']\n[[language-servers]]\nname='mock-ls'\ncommand='python3'",
)
.unwrap();
language.language_servers[0].args = vec![
server.to_string_lossy().into(),
log.to_string_lossy().into(),
ide_lsp::path_to_uri_string(&library),
ide_lsp::path_to_uri_string(&root),
];
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
languages: Arc::new(LanguageRegistry::from_configs(vec![language])),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let e = sink_events.clone();
Arc::new(move |event: IdeEvent| e.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
let invocation =
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
plugins.run_command("execution", "target", None, invocation()).unwrap();
let id = plugins.run_targets(&root)[0].id.clone();
let project = host.project("main").unwrap();
project
.settings
.set(
ide_workspace::SettingsScope::Project,
"languageServerTargets",
json!({"mock":{"target":id,"server":"mock-ls"}}),
)
.unwrap();
let doc = DocId::next();
project.lsp.did_open(doc, &source, &LanguageId::new("mock"), 1, "x");
// Links have no previously recorded location origin. Their URI must identify
// the right server by itself, including when its fragment is stripped.
let links = project.lsp.document_links(doc).await.unwrap();
let link = &links[0];
assert_eq!(link.fragment.as_deref(), Some("L2"));
assert!(link.url.is_none());
let linked =
ide_host::ops::doc::doc_open_virtual(&host, "main", link.path.as_ref().unwrap().to_string_lossy().into_owned())
.await
.unwrap();
assert!(linked.read_only);
let locations = tokio::time::timeout(
Duration::from_secs(15),
project.lsp.goto(doc, Pos::new(0, 0), ide_lsp::types::GotoKind::Definition),
)
.await
.unwrap()
.unwrap();
assert_eq!(locations[0].path.to_path_buf(), root.join("definition.mock"));
assert!(log.exists(), "initialize reached the fake server with the mapped root");
for _ in 0..200 {
if events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))) { break; }
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))));
let uri = locations[1].uri.clone().expect("external paths are virtual documents");
assert!(uri.starts_with("openidea-target:"));
let opened = ide_host::ops::doc::doc_open_virtual(&host, "main", uri.clone()).await.unwrap();
assert!(opened.read_only);
assert_eq!(project.documents.text_of(std::path::Path::new(&uri)).unwrap(), "read only library");
assert_eq!(std::fs::read_to_string(&reads).unwrap(), library.to_string_lossy());
assert!(
project
.documents
.apply_edits(opened.id, &[ide_core::TextEdit { range: Default::default(), new_text: "bad".into() }])
.is_err()
);
for operation in [
ide_core::FileEdit::Create { path: uri.clone().into(), overwrite: true },
ide_core::FileEdit::Delete { path: uri.clone().into(), recursive: false },
ide_core::FileEdit::Rename { from: uri.clone().into(), to: root.join("copy.mock").into() },
ide_core::FileEdit::Edit { path: uri.clone().into(), version: None, edits: Vec::new() },
] {
let mut edit = ide_core::WorkspaceEdit { files: vec![operation], ..Default::default() };
assert!(project.documents.prepare_workspace_edit(&mut edit).is_err());
}
// A library tab must not prevent ordinary project refactorings.
project
.documents
.apply_workspace_edit(&ide_core::WorkspaceEdit {
files: vec![ide_core::FileEdit::Create { path: root.join("created.mock").into(), overwrite: false }],
..Default::default()
})
.unwrap();
assert!(root.join("created.mock").exists());
// Changing the project setting restarts on the host, then back inside the target.
let selection = json!({"mock":{"target":id,"server":"mock-ls"}});
for value in [json!({}), selection.clone()] {
project.settings.set(ide_workspace::SettingsScope::Project, "languageServerTargets", value.clone()).unwrap();
project.lsp.reconfigure().await;
let expected = if value == json!({}) { ide_lsp::path_to_uri_string(&root) } else { "file:///container".into() };
wait_root(&log, &expected).await;
}
// Withdrawal invalidates reads, even when contents were cached earlier.
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
plugins.run_command("execution", "target", None, invocation()).unwrap();
wait_root(&log, "file:///container").await;
plugins.set_enabled("execution", false).unwrap();
assert!(project.lsp.virtual_document(&uri).await.is_err());
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
host.shutdown().await;
plugins.shutdown();
}
async fn wait_root(log: &std::path::Path, expected: &str) {
for _ in 0..500 {
let text = std::fs::read_to_string(log).unwrap_or_default();
if text
.lines()
.last()
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.is_some_and(|v| v["rootUri"] == expected)
{
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("server did not initialize with {expected}: {}", std::fs::read_to_string(log).unwrap_or_default());
}
-1
View File
@@ -48,7 +48,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"clipboard_has_files",
"clipboard_image",
"clipboard_set_file_list",
"clipboard_write_text",
"jdk_download_vendors",
"jdk_download_releases",
"jdk_download_location",
-2
View File
@@ -197,8 +197,6 @@ async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
diagnostics: Vec::new(),
original_diagnostics: None,
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
-4
View File
@@ -138,10 +138,6 @@ impl PathFilter {
&self.config.0 == extra_roots && self.config.1 == excludes
}
pub(crate) fn reload(&self) -> Self {
Self::new(&self.root, &self.config.0, &self.config.1)
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
-22
View File
@@ -332,28 +332,6 @@ impl ProjectIndex {
}
}
/// Refreshes ignore matchers and indexed entries in the affected subtrees.
pub fn ignore_rules_changed(&self, dirs: &[PathBuf]) {
let filter = self.shared.filter();
let mut affected = Vec::new();
for dir in dirs {
let path = if dir.is_absolute() { dir.clone() } else { self.root.join(dir) };
let path = dunce::canonicalize(&path).unwrap_or(path);
for root in filter.roots() {
if path.starts_with(root) {
affected.push(path.clone());
break;
}
if root.starts_with(&path) {
affected.push(root.to_path_buf());
}
}
}
if !affected.is_empty() {
self.shared.enqueue(Job::IgnoreRules(affected));
}
}
/// Boost for "recent files" ranking.
/// Records a recently opened file; a virtual document's URI (library
/// class) is kept verbatim, like IDEA lists library classes in Recent Files.
-63
View File
@@ -750,66 +750,3 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
#[test]
fn rule_changes_refresh_only_the_affected_index_subtree() {
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
let fx = Fixture::new(&[
(".git/HEAD", "ref: refs/heads/main\n"),
("sub/generated/file.ts", "export function generated() {}\n"),
("sub/local.ts", "export function localOnly() {}\n"),
("other/keep.ts", "export function keep() {}\n"),
]);
fx.write(rule, "");
let idx = fx.open();
assert_eq!(files(&idx, "file.ts"), ["sub/generated/file.ts"]);
fx.write(rule, "generated/\nlocal.ts\n");
let dirs = [fx.root.join(subtree)];
idx.ignore_rules_changed(&dirs);
assert!(idx.wait_idle(WAIT));
assert!(files(&idx, "file.ts").is_empty(), "{rule}: ignored directory remained indexed");
assert_eq!(files(&idx, "local.ts"), ["sub/local.ts"]);
assert!(symbols(&idx, "localOnly", SymbolFilter::All).is_empty());
assert_eq!(files(&idx, "keep.ts"), ["other/keep.ts"]);
fx.write(rule, "");
idx.ignore_rules_changed(&dirs);
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "file.ts"), ["sub/generated/file.ts"]);
assert_eq!(symbols(&idx, "localOnly", SymbolFilter::All), ["localOnly"]);
// The cached root matcher must also follow the rules for later watcher updates.
let fresh = fx.write("sub/generated/new.ts", "export function fresh() {}\n");
idx.update(&[fresh], &[]);
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "new.ts"), ["sub/generated/new.ts"]);
}
}
#[test]
fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
let fx = Fixture::new(&[
(".git/HEAD", "ref: refs/heads/main\n"),
(".git/info/exclude", ""),
("member/generated/item.txt", "x"),
]);
let idx = ProjectIndex::open(
&fx.root,
&fx.cache,
IndexRoots { include_root: false, folders: vec![fx.root.join("member")] },
vec![],
Arc::new(LanguageRegistry::load(None).unwrap()),
Arc::new(Syntax::new()),
Arc::new(NullSink),
)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
fx.write(".git/info/exclude", "generated/\n");
idx.ignore_rules_changed(std::slice::from_ref(&fx.root));
assert!(idx.wait_idle(WAIT));
assert!(files(&idx, "item.txt").is_empty());
fx.write(".git/info/exclude", "");
idx.ignore_rules_changed(std::slice::from_ref(&fx.root));
assert!(idx.wait_idle(WAIT));
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
}
+2 -37
View File
@@ -26,7 +26,6 @@ pub(crate) enum Job {
SaveRecent,
/// The roots or excludes changed: scan again (unchanged files are kept).
Rescan,
IgnoreRules(Vec<PathBuf>),
/// File types changed: re-parse the indexed files whose language under the
/// previous registry differs from the current one.
Reparse(std::sync::Arc<LanguageRegistry>),
@@ -213,10 +212,6 @@ impl Shared {
}
fn full_scan(&self) {
self.scan(None);
}
fn scan(&self, dirs: Option<&[PathBuf]>) {
let started = Instant::now();
self.indexing.store(true, Ordering::SeqCst);
self.emit_state();
@@ -232,11 +227,7 @@ impl Shared {
let filter = self.filter();
let mut found = Vec::new();
let roots: Vec<&Path> = match dirs {
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
None => filter.roots().collect(),
};
for root in roots {
for root in filter.roots() {
found.extend(filter.walk(root, &self.cancelled));
}
if self.is_cancelled() {
@@ -246,12 +237,7 @@ impl Shared {
let (todo, stale): (Vec<&Found>, Vec<PathBuf>) = {
let d = self.data.read();
let todo = found.iter().filter(|f| d.get(&f.path).is_none_or(|e| e.stale_for(f))).collect();
let stale = d
.paths()
.filter(|p| dirs.is_none_or(|dirs| dirs.iter().any(|dir| p.starts_with(dir))))
.filter(|p| !seen.contains(p.as_path()))
.cloned()
.collect();
let stale = d.paths().filter(|p| !seen.contains(p.as_path())).cloned().collect();
(todo, stale)
};
if !stale.is_empty() {
@@ -395,7 +381,6 @@ impl Shared {
let mut removed: Vec<PathBuf> = Vec::new();
let mut save_recent = false;
let mut rescan = false;
let mut rule_dirs = Vec::new();
let mut reparse: Vec<std::sync::Arc<LanguageRegistry>> = Vec::new();
for job in jobs {
match job {
@@ -403,29 +388,9 @@ impl Shared {
Job::Removed(p) => removed.extend(p),
Job::SaveRecent => save_recent = true,
Job::Rescan => rescan = true,
Job::IgnoreRules(dirs) => rule_dirs.extend(dirs),
Job::Reparse(before) => reparse.push(before),
}
}
if !rule_dirs.is_empty() {
{
let mut filter = self.filter.write();
*filter = std::sync::Arc::new(filter.reload());
}
rule_dirs.sort();
rule_dirs.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for dir in rule_dirs {
if !roots.iter().any(|root| dir.starts_with(root)) {
roots.push(dir);
}
}
if !rescan {
self.scan(Some(&roots));
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
}
}
if rescan {
// The scan also picks up whatever `changed` / `removed` reported.
self.full_scan();
+52 -6
View File
@@ -248,6 +248,7 @@ struct Inner {
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
kill: CancellationToken,
pid: Option<u32>,
transform: Option<Arc<dyn MessageTransform>>,
/// Fired when the reader task has stopped (EOF, error or kill).
reader_done: CancellationToken,
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
@@ -255,11 +256,38 @@ struct Inner {
exited: CancellationToken,
}
/// Translates protocol values before serialization and after parsing, including
/// responses to requests initiated by the server.
pub trait MessageTransform: Send + Sync + 'static {
fn outgoing(&self, value: &mut Value);
fn incoming(&self, value: &mut Value);
}
#[derive(Default)]
pub struct TransportOptions {
pub framing: Framing,
pub transform: Option<Arc<dyn MessageTransform>>,
}
impl From<Framing> for TransportOptions {
fn from(framing: Framing) -> Self {
Self { framing, transform: None }
}
}
impl Inner {
fn send_value(&self, value: &Value) -> Result<(), RpcError> {
if self.transport_closed.load(Ordering::Acquire) {
return Err(RpcError::Closed);
}
let mut translated;
let value = if let Some(transform) = &self.transform {
translated = value.clone();
transform.outgoing(&mut translated);
&translated
} else {
value
};
let body = serde_json::to_vec(value).map_err(|e| RpcError::Protocol(e.to_string()))?;
tracing::trace!(target: "ide_jsonrpc::wire", "--> {}", String::from_utf8_lossy(&body));
self.out.send(body).map_err(|_| RpcError::Closed)
@@ -387,6 +415,19 @@ impl Peer {
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
framing: Framing,
) -> std::io::Result<Self> {
Self::spawn_transport(command, args, cwd, env, handler, on_stderr, framing.into())
}
/// Starts a stdio peer with message translation installed before any IO.
pub fn spawn_transport(
command: &str,
args: &[String],
cwd: &std::path::Path,
env: &[(String, String)],
handler: Arc<dyn Handler>,
on_stderr: Box<dyn Fn(String) + Send + Sync>,
options: TransportOptions,
) -> std::io::Result<Self> {
use std::process::Stdio;
// Windows resolves a bare name only as `.exe`; find the file a child
@@ -412,7 +453,7 @@ impl Peer {
let pid = child.id();
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone()));
let stderr_task = stderr.map(|stderr| {
let name = command.to_owned();
@@ -466,7 +507,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None)
}
/// [`connect`](Self::connect) with the given wire framing.
@@ -475,7 +516,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
Self::start(reader, writer, handler, true, None, framing, None)
Self::start(reader, writer, handler, true, None, framing.into(), None)
}
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
@@ -492,7 +533,7 @@ impl Peer {
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None);
peer.supervise(child, None);
peer
}
@@ -503,13 +544,14 @@ impl Peer {
handler: Arc<dyn Handler>,
close_on_eof: bool,
pid: Option<u32>,
framing: Framing,
options: TransportOptions,
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let TransportOptions { framing, transform } = options;
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (closed, _) = watch::channel(None);
let inner = Arc::new(Inner {
@@ -520,6 +562,7 @@ impl Peer {
closed,
kill: CancellationToken::new(),
pid,
transform,
reader_done: CancellationToken::new(),
exited: CancellationToken::new(),
});
@@ -785,13 +828,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let msg: Value = match serde_json::from_slice(body) {
let mut msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
if let Some(transform) = &inner.transform {
transform.incoming(&mut msg);
}
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
+20 -1
View File
@@ -14,7 +14,7 @@ mod markers;
pub mod plugin_point;
mod registry;
mod syntax;
pub use markers::{arduino_sketch_root, is_arduino_sketch_dir, marked_root};
pub use markers::marked_root;
pub use plugin_point::PluginLanguages;
pub use registry::{FileTypes, LanguageRegistry};
pub use syntax::{
@@ -176,11 +176,30 @@ pub struct LanguageServerConfig {
/// Set on plugin languages: start exactly this approved program.
#[serde(skip)]
pub pinned: Option<ProgramPin>,
/// Host-only transport configuration; never accepted from language definitions.
#[serde(skip)]
pub target: Option<ServerTarget>,
/// Separates instances when languages sharing a server select different targets.
#[serde(skip)]
pub target_key: Option<String>,
}
/// A prepared plugin wrapper. The LSP client never resolves container binaries on the host.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerTarget {
pub id: String,
pub origin: Option<ide_core::ServerId>,
pub project_root: PathBuf,
pub container_root: String,
pub mapping: ide_core::path_mapping::PathMapping,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct DebuggerConfig {
/// Placement in plugin run targets; absent means unsupported.
#[serde(default)]
pub target_placement: Option<String>,
/// DAP adapter name, e.g. `codelldb`, `debugpy`, `js-debug`, `delve`.
pub adapter: String,
pub command: String,
-21
View File
@@ -19,27 +19,6 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
best
}
/// `dir` contains at least one `.ino` regular file.
pub fn is_arduino_sketch_dir(dir: &Path) -> bool {
std::fs::read_dir(dir).is_ok_and(|entries| {
entries.flatten().any(|entry| {
entry.path().extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ino"))
&& entry.file_type().is_ok_and(|kind| kind.is_file())
})
})
}
/// Nearest enclosing directory of `path` (including `path` when it is already a
/// directory) within `project` that contains a `.ino` file.
pub fn arduino_sketch_root(project: &Path, path: &Path) -> Option<PathBuf> {
let start = usize::from(!path.is_dir());
path.ancestors()
.skip(start)
.take_while(|dir| dir.starts_with(project))
.find(|dir| is_arduino_sketch_dir(dir))
.map(Path::to_path_buf)
}
/// `dir` contains `marker`: a relative path, a file name pattern with one
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
/// must contain (`pyproject.toml#tool.ruff`).
+2 -10
View File
@@ -70,7 +70,6 @@ const BUILTIN: &[(&str, &str)] = &[
("proto", include_str!("../../../languages/proto.toml")),
("python", include_str!("../../../languages/python.toml")),
("r", include_str!("../../../languages/r.toml")),
("restructuredtext", include_str!("../../../languages/restructuredtext.toml")),
("ruby", include_str!("../../../languages/ruby.toml")),
("rust", include_str!("../../../languages/rust.toml")),
("sass", include_str!("../../../languages/sass.toml")),
@@ -563,7 +562,6 @@ mod tests {
"R",
"RPM Changes",
"RPM Spec",
"reStructuredText",
"Ruby",
"SAS",
"Scala",
@@ -630,12 +628,6 @@ mod tests {
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
for path in ["guide.rst", "README.RST"] {
assert_eq!(detect(&reg, path), "restructuredtext");
}
let rst = reg.get(&LanguageId::new("restructuredtext")).unwrap();
assert_eq!(rst.ui_mode.as_deref(), Some("reStructuredText"));
assert_eq!(rst.line_comment.as_deref(), Some(".."));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
@@ -1007,11 +999,11 @@ mod tests {
}
#[test]
fn astro_server_leaves_typescript_sdk_resolution_to_the_launcher() {
fn astro_server_reads_the_project_typescript() {
let reg = reg();
let ls = &reg.get(&LanguageId::new("astro")).unwrap().language_servers[0];
let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str());
assert_eq!(tsdk, None);
assert_eq!(tsdk, Some("node_modules/typescript/lib"));
}
#[test]
-1
View File
@@ -206,7 +206,6 @@ impl SyntaxCache {
// their trees join the cache.
.or_else(|| self.syntax.web_outline(lang, text))
.or_else(|| super::external::outline(lang, text))
.or_else(|| self.syntax.builtin_outline(lang, text))
},
)
}
-7
View File
@@ -9,7 +9,6 @@ mod highlight;
pub(crate) mod lines;
mod literals;
mod outline;
mod rst;
mod selection;
mod sfc;
mod web;
@@ -178,15 +177,9 @@ impl Syntax {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
})
.or_else(|| self.builtin_outline(lang, text))
.unwrap_or_default()
}
/// Outline for languages without a grammar that have a built-in parser.
fn builtin_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
(lang.as_str() == "restructuredtext" && text.len() <= MAX_PARSE_BYTES).then(|| rst::outline(text))
}
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
/// for every other language, CSS containers included.
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
-131
View File
@@ -1,131 +0,0 @@
//! reStructuredText section titles for the outline. No bundled grammar nests
//! sections (tree-sitter-rst emits them flat), so levels follow docutils: each
//! adornment style (character, with or without overline) takes the next level
//! the first time it appears.
use super::lines::LineIndex;
use ide_core::{OutlineItem, SymbolKind};
struct Line<'a> {
start: usize,
text: &'a str,
}
struct Heading {
level: usize,
start: usize,
end: usize,
name_start: usize,
name_end: usize,
}
pub(crate) fn outline(text: &str) -> Vec<OutlineItem> {
let lines: Vec<Line> = text
.split_inclusive('\n')
.scan(0, |at, raw| {
let start = *at;
*at += raw.len();
Some(Line { start, text: raw.trim_end_matches(['\n', '\r']) })
})
.collect();
let mut heads = headings(&lines);
for k in 0..heads.len() {
let end = heads[k + 1..].iter().find(|h| h.level <= heads[k].level).map_or(text.len(), |h| h.start);
heads[k].end = heads[k].start + text[heads[k].start..end].trim_end().len();
}
let index = LineIndex::new(text);
let mut i = 0;
nest(&heads, &mut i, None, text, &index)
}
fn headings(lines: &[Line]) -> Vec<Heading> {
let mut styles: Vec<(char, bool)> = Vec::new();
let mut out = Vec::new();
let mut push = |style: (char, bool), start: usize, title: &Line| {
let level = styles.iter().position(|&s| s == style).unwrap_or_else(|| {
styles.push(style);
styles.len() - 1
});
let name = title.text.trim();
let name_start = title.start + (title.text.len() - title.text.trim_start().len());
out.push(Heading { level, start, end: 0, name_start, name_end: name_start + name.len() });
};
// A title starts a new block: it follows a blank line, the start, or another title.
let mut at_block_start = true;
let mut i = 0;
while i < lines.len() {
let line = &lines[i];
if line.text.trim().is_empty() {
at_block_start = true;
i += 1;
continue;
}
if at_block_start {
if let Some(over) = adornment(line.text) {
if let [title, under, ..] = &lines[i + 1..]
&& !title.text.trim().is_empty()
&& adornment(title.text).is_none()
&& adornment(under.text) == Some(over)
&& fits(title.text.trim_end(), over.1)
{
push((over.0, true), line.start, title);
i += 3;
continue;
}
} else if !line.text.starts_with([' ', '\t'])
&& !starts_body_element(line.text)
&& let Some(under) = lines.get(i + 1).and_then(|l| adornment(l.text))
&& fits(line.text.trim_end(), under.1)
{
push((under.0, false), line.start, line);
i += 2;
continue;
}
}
// Indented lines (literal blocks after `::`, directive bodies, quotes)
// never hold titles: they are skipped here because adornments and
// underlined titles must start in column 0.
at_block_start = false;
i += 1;
}
out
}
/// `(character, length)` of a line made of one repeated ASCII punctuation character.
fn adornment(line: &str) -> Option<(char, usize)> {
let line = line.trim_end();
let c = line.chars().next().filter(char::is_ascii_punctuation)?;
line.chars().all(|x| x == c).then_some((c, line.len()))
}
/// docutils accepts a short adornment (with a warning) unless it is shorter
/// than 4 characters, which reads as ordinary text instead.
// ponytail: counts chars, not display columns; wide CJK titles get a looser check.
fn fits(title: &str, adornment_len: usize) -> bool {
adornment_len >= title.chars().count() || adornment_len >= 4
}
/// Lines docutils parses as another body element before trying a title:
/// explicit markup (`..`, `__`), bullets, field lists, doctests, line blocks.
fn starts_body_element(line: &str) -> bool {
let marker = |m: &str| line == m || line.strip_prefix(m).is_some_and(|r| r.starts_with(' '));
["..", "__", "-", "*", "+", "•", ">>>", "|"].into_iter().any(marker)
|| (line.starts_with(':') && line[1..].find(':').is_some_and(|k| k > 0 && marker(&line[..k + 2])))
}
fn nest(heads: &[Heading], i: &mut usize, parent: Option<usize>, text: &str, index: &LineIndex) -> Vec<OutlineItem> {
let mut items = Vec::new();
while let Some(h) = heads.get(*i).filter(|h| parent.is_none_or(|p| h.level > p)) {
*i += 1;
items.push(OutlineItem {
name: text[h.name_start..h.name_end].to_owned(),
detail: None,
kind: SymbolKind::Key,
range: index.range(h.start, h.end),
selection_range: index.range(h.name_start, h.name_end),
children: nest(heads, i, Some(h.level), text, index),
visibility: None,
});
}
items
}
-88
View File
@@ -1638,91 +1638,3 @@ fn script_outlines_compose_positions_across_multiple_blocks() {
fn script_outline_attaches_to_case_insensitive_html_tags() {
check_outline("html", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
}
#[test]
fn outline_restructuredtext_levels_follow_first_use_of_each_adornment() {
let src = "\
=========
Project
=========
Install
~~~~~~~
From source
-----------
Literal block::
Not a title
-----------
Intro
--
A paragraph
with a line
------------
.. comment
-----------
Usage
~~~~~
.. note::
Indented
--------
Short underline, still a title
------
Underline only
==============
";
check_outline(
"restructuredtext",
src,
"
Key Project
Key Install
Key From source
Key Usage
Key Short underline, still a title
Key Underline only
",
);
let items = syntax().outline(&lang("restructuredtext"), src);
let project = &items[0];
assert_eq!(project.range.start, Pos::new(0, 0));
assert_eq!((project.selection_range.start, project.selection_range.end), (Pos::new(1, 1), Pos::new(1, 8)));
let install = &project.children[0];
assert_eq!((install.range.start, install.range.end), (Pos::new(4, 0), Pos::new(23, 11)));
assert!(syntax().outline(&lang("restructuredtext"), "Title\n\n").is_empty());
}
#[test]
fn outline_restructuredtext_unicode_crlf_and_cached_edits() {
use ide_core::DocId;
use std::sync::Arc;
let src = "=========\r\n 中文😀\r\n=========\r\n\r\n子章节\r\n------\r\n\r\nBody\r\n\r\nOther\r\n------\r\n";
let id = DocId::next();
let language = lang("restructuredtext");
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
cache.open(id, &language, 0);
let items = cache.outline(id, &language, 0, src);
assert_eq!(items, syntax().outline(&language, src));
assert_eq!(items[0].name, "中文😀");
assert_eq!(items[0].selection_range, Range::new(Pos::new(1, 1), Pos::new(1, 5)));
assert_eq!(items[0].children[0].name, "子章节");
assert_eq!(items[0].children[0].selection_range, Range::new(Pos::new(4, 0), Pos::new(4, 3)));
assert_eq!(items[0].children[0].range.end, Pos::new(7, 4));
assert_eq!(items[0].children[1].name, "Other");
let edited = src.replace("子章节", "新章节😀");
let updated = cache.outline(id, &language, 1, &edited);
assert_eq!(updated, syntax().outline(&language, &edited));
assert_eq!(updated[0].children[0].selection_range.end, Pos::new(4, 5));
assert_ne!(items, updated);
}
-210
View File
@@ -1,210 +0,0 @@
//! Arduino sketches need their own board context, including their C/C++ sources.
use crate::LspManager;
use crate::server::Server;
use ide_core::{LanguageId, Location};
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) const SERVER: &str = "arduino-language-server";
pub(crate) fn sketch_root(project: &Path, file: &Path) -> Option<PathBuf> {
ide_language::arduino_sketch_root(project, file)
}
pub(crate) fn configure_client_capabilities(capabilities: &mut Value) {
// Arduino's proxy cannot decode the boolean full form, panics on inlayHint (#225),
// and cannot handle clangd's semanticTokens refresh request (#125).
for section in ["textDocument", "workspace"] {
if let Some(section) = capabilities[section].as_object_mut() {
section.remove("semanticTokens");
section.remove("inlayHint");
}
}
// ALS uses go.bug.st/lsp (LSP 3.14) which requires a `range` field on `lsp.TextEdit`.
// Clangd returns `InsertReplaceEdit` (`insert` + `replace`) when `insertReplaceSupport` is advertised,
// causing ALS to drop the connection with "json: undefined required field range into lsp.TextEdit".
if let Some(completion) = capabilities.get_mut("textDocument").and_then(|t| t.get_mut("completion"))
&& let Some(item) = completion.get_mut("completionItem").and_then(|c| c.as_object_mut())
{
item.remove("insertReplaceSupport");
}
}
pub(crate) fn sanitize_server_capabilities(capabilities: &mut Value) {
// Arduino Language Server's request dispatcher advertises documentSymbol and
// workspaceSymbol, but panics with "not implemented" / "unimplemented" when called.
// Tree-sitter handles C++ outline/symbols in OpenIDEA without hitting ALS.
if let Some(obj) = capabilities.as_object_mut() {
obj.remove("documentSymbolProvider");
obj.remove("workspaceSymbolProvider");
}
}
impl LspManager {
pub(crate) fn arduino_servers(&self, language: &LanguageId, path: &Path) -> Option<Vec<Arc<Server>>> {
if !matches!(language.as_str(), "c" | "cpp") {
return None;
}
let configs = self.language_servers(&LanguageId::new("arduino"));
let cfg = configs.iter().find(|cfg| cfg.name == SERVER)?;
let project = self.project_root_for(path);
if sketch_root(&project, path).is_some() {
let server = self.server_for(cfg, &LanguageId::new("arduino"), path);
server.add_language(language);
let mut servers = vec![server];
servers.extend(
self.language_servers(language)
.iter()
.filter(|cfg| cfg.name != "clangd" && cfg.name != SERVER)
.filter(|cfg| {
!cfg.require_root_marker
|| crate::launch::marked_root(&project, path, &cfg.root_markers).is_some()
})
.map(|cfg| self.server_for(cfg, language, path)),
);
return Some(servers);
}
if !path.starts_with(&project)
&& let Some(server) = self.origin_server(&crate::uri::path_to_uri_string(path))
&& server.name == SERVER
{
return Some(vec![server]);
}
None
}
pub(crate) fn note_arduino_sources(&self, server: &Arc<Server>, locations: &[Location]) {
if server.name != SERVER {
return;
}
let mut origins = None;
for location in locations {
let path: &Path = &location.path;
if location.uri.is_none() && !path.starts_with(self.project_root_for(path)) {
origins
.get_or_insert_with(|| self.virtual_origins.lock())
.insert(crate::uri::path_to_uri_string(path), server.id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_server_allows_diagnostics() {
let registry = ide_language::LanguageRegistry::load(None).unwrap();
let language = registry.get(&LanguageId::new("arduino")).unwrap();
let cfg = language.language_servers.iter().find(|cfg| cfg.name == SERVER).unwrap();
assert!(crate::caps::Restriction::new(SERVER, &cfg.features, &cfg.code_action_kinds).diagnostics());
}
#[tokio::test]
async fn inspection_checks_the_sketch_server_and_root_for_all_sources() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let expected_root = sketch.clone();
manager.set_config_hook(Box::new(move |cfg, server_root| {
cfg.command = std::env::current_exe().unwrap().to_string_lossy().into_owned();
if cfg.name != SERVER || server_root != expected_root {
cfg.launch_error = Some("unavailable".into());
}
}));
for name in ["blink.ino", "helper.cpp", "helper.h"] {
assert!(manager.serves_path(&sketch.join(name)), "{name}");
}
assert!(!manager.serves_path(&root.join("main.cpp")));
}
#[test]
fn nested_sketches_keep_separate_board_roots() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let inner = root.join("examples/blink");
std::fs::create_dir_all(inner.join("src")).unwrap();
std::fs::write(root.join("main.ino"), "").unwrap();
std::fs::write(inner.join("blink.ino"), "").unwrap();
assert_eq!(sketch_root(root, &inner.join("blink.ino")), Some(inner.clone()));
assert_eq!(sketch_root(root, &inner.join("src/helper.cpp")), Some(inner));
assert_eq!(sketch_root(root, &root.join("helper.h")), Some(root.to_path_buf()));
assert_eq!(sketch_root(&root.join("examples"), &root.join("other.cpp")), None);
}
#[test]
fn ordinary_cpp_projects_and_ino_named_directories_are_not_sketches() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("not-a-file.ino")).unwrap();
assert_eq!(sketch_root(dir.path(), &dir.path().join("main.cpp")), None);
}
#[tokio::test]
async fn cpp_routing_is_scoped_to_sketches_and_respects_disabled_arduino() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let sketch = root.join("examples/blink");
std::fs::create_dir_all(&sketch).unwrap();
std::fs::write(sketch.join("blink.ino"), "").unwrap();
let registry = Arc::new(ide_language::LanguageRegistry::load(None).unwrap());
let manager = LspManager::new(root.to_path_buf(), registry, Arc::new(|_: ide_core::IdeEvent| {}));
let cpp = LanguageId::new("cpp");
let sketch_servers = manager.servers_for(&cpp, &sketch.join("helper.cpp"));
assert_eq!(sketch_servers.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), [SERVER]);
assert_eq!(sketch_servers[0].root, sketch);
assert!(sketch_servers[0].languages().contains(&LanguageId::new("arduino")));
assert_eq!(manager.servers_for(&cpp, &root.join("main.cpp"))[0].name, "clangd");
manager.set_servers_hook(Box::new(
|language, defined| {
if language.as_str() == "arduino" { Vec::new() } else { defined.to_vec() }
},
));
assert_eq!(manager.servers_for(&cpp, &sketch.join("helper.cpp"))[0].name, "clangd");
}
#[test]
fn capabilities_are_sanitized_for_arduino_proxy_quirks() {
let mut client = serde_json::json!({
"textDocument": {
"semanticTokens": { "dynamicRegistration": false },
"inlayHint": { "dynamicRegistration": false },
"completion": {
"completionItem": {
"snippetSupport": true,
"insertReplaceSupport": true
}
}
},
"workspace": {
"semanticTokens": { "refreshSupport": true },
"inlayHint": { "refreshSupport": true }
}
});
configure_client_capabilities(&mut client);
assert!(client["textDocument"].get("semanticTokens").is_none());
assert!(client["textDocument"].get("inlayHint").is_none());
assert!(client["textDocument"]["completion"]["completionItem"].get("insertReplaceSupport").is_none());
assert_eq!(client["textDocument"]["completion"]["completionItem"]["snippetSupport"], true);
assert!(client["workspace"].get("semanticTokens").is_none());
assert!(client["workspace"].get("inlayHint").is_none());
let mut server = serde_json::json!({
"documentSymbolProvider": {},
"workspaceSymbolProvider": {},
"completionProvider": { "triggerCharacters": ["."] },
"hoverProvider": true
});
sanitize_server_capabilities(&mut server);
assert!(server.get("documentSymbolProvider").is_none());
assert!(server.get("workspaceSymbolProvider").is_none());
assert!(server.get("completionProvider").is_some());
assert!(server.get("hoverProvider").is_some());
}
}
+13 -96
View File
@@ -301,7 +301,6 @@ pub fn completion_item(conv: &Conv, path: &Path, item: lsp::CompletionItem, serv
let mut out = CompletionItem {
label: item.label.clone(),
label_detail: None,
label_description: None,
detail: None,
kind: None,
documentation: None,
@@ -350,9 +349,6 @@ pub fn overlay_completion(conv: &Conv, path: &Path, out: &mut CompletionItem, it
if let Some(d) = label_details.and_then(|d| d.detail) {
out.label_detail = Some(d);
}
if let Some(d) = description.clone() {
out.label_description = Some(d);
}
if let Some(d) = detail.or(description) {
out.detail = Some(d);
}
@@ -429,12 +425,10 @@ pub fn lsp_completion_item(text: &DocText, enc: Encoding, item: &CompletionItem)
});
lsp::CompletionItem {
label: item.label.clone(),
label_details: (item.label_detail.is_some() || item.label_description.is_some()).then(|| {
lsp::CompletionItemLabelDetails {
detail: item.label_detail.clone(),
description: item.label_description.clone(),
}
}),
label_details: item
.label_detail
.clone()
.map(|detail| lsp::CompletionItemLabelDetails { detail: Some(detail), description: None }),
kind: item.kind.map(lsp_completion_kind),
detail: item.detail.clone(),
documentation: item.documentation.as_ref().map(lsp_documentation),
@@ -568,18 +562,10 @@ pub fn lsp_command(c: &Command) -> lsp::Command {
}
}
pub fn code_action(
conv: &Conv,
path: &Path,
a: lsp::CodeActionOrCommand,
server: ServerId,
server_name: &str,
) -> CodeAction {
pub fn code_action(conv: &Conv, a: lsp::CodeActionOrCommand, server: ServerId) -> CodeAction {
match a {
lsp::CodeActionOrCommand::Command(c) => CodeAction {
title: c.title.clone(),
diagnostics: Vec::new(),
original_diagnostics: None,
kind: None,
is_preferred: false,
disabled_reason: None,
@@ -590,13 +576,6 @@ pub fn code_action(
},
lsp::CodeActionOrCommand::CodeAction(a) => CodeAction {
title: a.title,
original_diagnostics: a.diagnostics.as_ref().and_then(|d| serde_json::to_string(d).ok()),
diagnostics: a
.diagnostics
.unwrap_or_default()
.into_iter()
.map(|d| diagnostic(conv, path, d, server_name))
.collect(),
kind: a.kind.map(|k| k.as_str().to_owned()),
is_preferred: a.is_preferred.unwrap_or(false),
disabled_reason: a.disabled.map(|d| d.reason),
@@ -612,7 +591,7 @@ pub fn lsp_code_action(a: &CodeAction) -> lsp::CodeAction {
lsp::CodeAction {
title: a.title.clone(),
kind: a.kind.clone().map(lsp::CodeActionKind::from),
diagnostics: a.original_diagnostics.as_deref().and_then(|d| serde_json::from_str(d).ok()),
diagnostics: None,
edit: None,
command: a.command.as_ref().map(lsp_command),
is_preferred: a.is_preferred.then_some(true),
@@ -998,6 +977,7 @@ pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option<D
let target = target.as_str();
let (file, url) = match uri_to_path(target) {
Some(p) => (Some(p), None),
None if target.starts_with("openidea-target:") => (Some(PathBuf::from(target.split('#').next()?)), None),
None => (None, Some(target.to_owned())),
};
let fragment = file
@@ -1087,66 +1067,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn code_action_diagnostics_use_ide_coordinates_and_round_trip_for_resolve() {
let path = Path::new("/go.mod");
let conv = Conv::new(Encoding::Utf8, |_| Some(Arc::new(DocText::new("😀 dependency"))));
let raw: lsp::Diagnostic = serde_json::from_value(json!({
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 15 } },
"message": "github.com/sergi/go-diff is not used in this module",
"severity": 2,
"code": 123,
"source": "go mod tidy",
"data": { "fix": "remove" }
}))
.unwrap();
let incoming = lsp::CodeAction {
title: "Remove dependency: github.com/sergi/go-diff".into(),
diagnostics: Some(vec![raw.clone()]),
..Default::default()
};
let action = code_action(&conv, path, lsp::CodeActionOrCommand::CodeAction(incoming), ServerId(1), "gopls");
assert_eq!(action.diagnostics, vec![diagnostic(&conv, path, raw.clone(), "gopls")]);
assert_eq!(action.diagnostics[0].range, Range::new(Pos::new(0, 3), Pos::new(0, 13)));
assert_eq!(action.diagnostics[0].code.as_deref(), Some("123"));
let action: CodeAction = serde_json::from_value(serde_json::to_value(action).unwrap()).unwrap();
assert_eq!(lsp_code_action(&action).diagnostics, Some(vec![raw.clone()]));
let resolved = merge_code_action(&conv, action, lsp::CodeAction::default());
assert_eq!(lsp_code_action(&resolved).diagnostics, Some(vec![raw]));
assert_eq!(resolved.diagnostics.len(), 1);
}
#[test]
fn code_actions_without_diagnostics_keep_them_absent_for_resolve() {
let conv = Conv::utf16();
for incoming in [
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "Fix".into(), ..Default::default() }),
lsp::CodeActionOrCommand::Command(lsp::Command {
title: "Fix".into(),
command: "fix".into(),
arguments: None,
}),
] {
let action = code_action(&conv, Path::new("/a"), incoming, ServerId(1), "srv");
assert!(action.diagnostics.is_empty());
assert!(lsp_code_action(&action).diagnostics.is_none());
}
let raw = lsp::Diagnostic::new_simple(lsp::Range::default(), "problem".into());
let expected = diagnostic(&conv, Path::new("/a"), raw.clone(), "srv");
let action = code_action(
&conv,
Path::new("/a"),
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
diagnostics: Some(vec![raw]),
..Default::default()
}),
ServerId(1),
"srv",
);
assert_eq!(action.diagnostics, vec![expected]);
assert_eq!(action.diagnostics[0].source.as_deref(), Some("srv"));
}
#[test]
fn merged_rename_edits_keep_the_first_servers_ones() {
let edit = |l: u32, c: u32, text: &str| TextEdit {
@@ -1289,8 +1209,7 @@ mod tests {
assert_eq!(a.insert_text, "println!($0)");
assert!(a.is_snippet);
assert_eq!(a.label_detail.as_deref(), Some("(…)"));
assert_eq!(a.label_description.as_deref(), Some("std"));
assert_eq!(a.detail.as_deref(), Some("std"), "no `detail` from the server: the description stands in");
assert_eq!(a.detail.as_deref(), Some("std"));
assert_eq!(a.kind, Some(CompletionKind::Function));
assert_eq!(a.replace_range, Some(Range::new(p(0, 4), p(0, 6))));
assert_eq!(a.replace_range_tab, Some(Range::new(p(0, 4), p(0, 9))));
@@ -1306,9 +1225,6 @@ mod tests {
let back = lsp_completion_item(&text, Encoding::Utf16, &a);
assert_eq!(back.data, Some(json!({"k": 1})));
assert!(matches!(back.text_edit, Some(lsp::CompletionTextEdit::InsertAndReplace(_))));
let details = back.label_details.expect("label details survive the resolve round trip");
assert_eq!(details.detail.as_deref(), Some("(…)"));
assert_eq!(details.description.as_deref(), Some("std"));
}
#[test]
@@ -1339,10 +1255,6 @@ mod tests {
let item = completion_item(&Conv::utf16(), Path::new("/Main.java"), items[0].clone(), ServerId(1));
assert_eq!(item.replace_range, Some(Range::new(p(2, 19), p(2, end))));
assert_eq!(item.replace_range_tab, None);
// The list shows `print (Object obj)` and `void`; the long signature is only for the docs panel.
assert_eq!(item.label_detail.as_deref(), Some("(Object obj)"));
assert_eq!(item.label_description.as_deref(), Some("void"));
assert_eq!(item.detail.as_deref(), Some("PrintStream.print(Object obj) : void"));
assert_eq!(item.filter_text.as_deref(), Some("print(Object obj) : void"));
assert_eq!(item.insert_text, "print(${0});");
assert!(item.is_snippet);
@@ -1559,6 +1471,11 @@ mod tests {
assert_eq!(file.path.as_deref(), Some(Path::new("/p/a b.md")));
assert_eq!(file.fragment.as_deref(), Some("L12"));
assert_eq!(file.range, Range::new(p(0, 6), p(0, 15)));
let virtual_link =
document_link(&conv, here, link(Some("openidea-target://target/7/usr/lib/a.php#L2"))).unwrap();
assert_eq!(virtual_link.path.as_deref(), Some(Path::new("openidea-target://target/7/usr/lib/a.php")));
assert_eq!(virtual_link.fragment.as_deref(), Some("L2"));
assert!(virtual_link.url.is_none());
let web = document_link(&conv, here, link(Some("https://example.com/#top"))).unwrap();
assert_eq!((web.path, web.url.as_deref(), web.fragment), (None, Some("https://example.com/#top"), None));
assert!(document_link(&conv, here, link(None)).is_none());
-11
View File
@@ -63,17 +63,6 @@ impl LspManager {
/// run doesn't prompt to install every language it meets.
pub fn serves_path(&self, path: &Path) -> bool {
let lang = self.languages.detect(path, None);
let arduino = if lang.as_str() == "arduino" {
Some(self.servers_for(&lang, path))
} else {
self.arduino_servers(&lang, path)
};
if let Some(servers) = arduino {
return servers.iter().any(|server| {
let cfg = self.configured(server);
cfg.launch_error.is_none() && self.launcher.available(&cfg, &server.root)
});
}
self.language_servers(&lang).iter().any(|ls| {
if ls.require_root_marker
&& crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none()
+30
View File
@@ -95,6 +95,33 @@ impl Launcher for ProcessLauncher {
on_stderr: Box<dyn Fn(String) + Send + Sync>,
cancel: &CancellationToken,
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
if let Some(target) = &config.target {
let argv =
format!("argv: {:?}", std::iter::once(&config.command).chain(config.args.iter()).collect::<Vec<_>>());
on_stderr(argv.clone());
let binary = config
.pinned
.as_ref()
.ok_or_else(|| LaunchError::Config("target wrapper has no executable pin".into()))?
.verify()
.map_err(LaunchError::Failed)?
.to_owned();
let env = config.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>();
return Peer::spawn_transport(
&binary.to_string_lossy(),
&config.args,
&target.project_root,
&env,
handler,
on_stderr,
ide_jsonrpc::TransportOptions {
transform: Some(Arc::new(crate::target::TargetTransform(target.clone()))),
..Default::default()
},
)
.map(|peer| (peer, Some(binary)))
.map_err(|e| LaunchError::Failed(format!("{argv}: {e}")));
}
let search = server_search_path(config);
let binary = match &config.pinned {
// A plugin's server: the approved program, unchanged since.
@@ -125,6 +152,9 @@ impl Launcher for ProcessLauncher {
}
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
if config.target.is_some() {
return true;
}
if let Some(pin) = &config.pinned {
return pin.path.is_file();
}
+96 -94
View File
@@ -1,13 +1,12 @@
//! Language server manager.
//!
//! Servers start lazily per `(server name, workspace root)` when a document of
//! Servers start lazily per `(server name, workspace root, target)` when a document of
//! a matching language opens. Several servers may serve one language (e.g.
//! `vtsls` + `eslint`); results are merged. Every request takes positions in
//! IDE coordinates (UTF-16, 0-based) and returns IDE types from [`types`],
//! never `lsp-types`, so the IPC surface stays stable.
mod angular;
mod arduino;
mod caps;
mod clang_tidy;
mod convert;
@@ -25,6 +24,7 @@ mod loading;
mod pull;
mod scope;
mod server;
mod target;
mod tcp;
mod text;
mod ts_file_moves;
@@ -86,6 +86,10 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
/// settings before each launch; receives the server's workspace root.
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
pub type ConfigHook = Arc<ConfigFn>;
/// Returns true when target configuration replaces host toolchain configuration.
pub type TargetConfigFn = dyn Fn(&LanguageId, &mut LanguageServerConfig) -> bool + Send + Sync;
/// Fetches a bounded read-only container document after rechecking grants and trust.
pub type TargetReadFn = dyn Fn(&ide_language::ServerTarget, &str) -> IdeResult<String> + Send + Sync;
/// Prepares dependencies on a blocking worker immediately before launching an installed server.
pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync;
/// The servers a language uses, from its definition's list (user settings
@@ -182,7 +186,7 @@ struct JdtlsWorkspace {
}
fn is_local_jdtls(config: &LanguageServerConfig) -> bool {
config.name == "jdtls"
config.name == "jdtls" && config.target_key.is_none() && config.target.is_none()
}
pub struct LspManager {
@@ -198,6 +202,8 @@ pub struct LspManager {
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
config_hook: RwLock<Option<ConfigHook>>,
target_hook: RwLock<Option<Arc<TargetConfigFn>>>,
target_read_hook: RwLock<Option<Arc<TargetReadFn>>>,
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
@@ -406,6 +412,8 @@ impl LspManager {
apply_edit_hook: RwLock::new(None),
applied_edits: tokio::sync::broadcast::channel(64).0,
config_hook: RwLock::new(None),
target_hook: RwLock::new(None),
target_read_hook: RwLock::new(None),
prepare_hook: RwLock::new(None),
servers_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
@@ -494,22 +502,22 @@ impl LspManager {
fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc<Server> {
let shared = is_local_jdtls(cfg).then(|| self.jdtls_workspace_state());
let mut servers = self.servers.lock();
let project = self.project_root_for(path);
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
let root = if shared.as_ref().is_some_and(|state| state.shared) {
self.project_root.clone()
} else {
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| {
// Outside the project (library sources, …): reuse a running instance.
servers
.iter()
.filter(|s| s.name == cfg.name)
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
.min_by_key(|s| s.root != self.project_root)
.map(|s| s.root.clone())
.unwrap_or_else(|| self.project_root.clone())
})
};
if let Some(s) = servers.iter().find(|s| s.name == cfg.name && s.root == root) {
if let Some(s) =
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
{
s.add_language(language);
return s.clone();
}
@@ -522,9 +530,6 @@ impl LspManager {
/// only while all servers listed before it are missing. One that requires a
/// root marker serves only files below one, and stands in for no other.
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
if let Some(servers) = self.arduino_servers(language, path) {
return servers;
}
let mut out = Vec::new();
let mut available_before = false;
for sc in &self.language_servers(language) {
@@ -560,8 +565,13 @@ impl LspManager {
/// enabled in settings.
fn attach_fallbacks(&self) {
let _gate = self.injections.gate.lock();
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let open: Vec<(DocId, LanguageId, PathBuf)> = self
.docs
.lock()
.iter()
.filter(|(_, d)| !d.uri.as_str().starts_with("openidea-target:"))
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
.collect();
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
let mut start = Vec::new();
@@ -663,7 +673,8 @@ impl LspManager {
}
tail.set_effective_config(generation, cfg.clone());
let prepare = me.prepare_hook.read().clone();
if !launch_cancel.is_cancelled()
if cfg.target.is_none()
&& !launch_cancel.is_cancelled()
&& me.launcher.available(&cfg, &tail.root)
&& let Some(prepare) = prepare
{
@@ -674,7 +685,7 @@ impl LspManager {
let root = tail.root.clone();
if let Some(message) = &cfg.launch_error {
// A missing binary still asks to be installed first.
let missing = !me.launcher.available(&cfg, &root);
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
}
if !me.execution_allowed.load(Ordering::Acquire) || launch_cancel.is_cancelled() {
@@ -764,11 +775,7 @@ impl LspManager {
if let Some(info) = result.get("serverInfo") {
tracing::info!("{} initialized: {info}", server.name);
}
let mut caps_val = result.get("capabilities").cloned().unwrap_or_else(|| json!({}));
if server.name == arduino::SERVER {
arduino::sanitize_server_capabilities(&mut caps_val);
}
let mut caps = ServerCaps::parse(caps_val);
let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})));
let cfg = server.effective_config();
caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds));
caps
@@ -914,9 +921,6 @@ impl LspManager {
"capabilities": caps::client_capabilities(),
"trace": "off",
});
if server.name == arduino::SERVER {
arduino::configure_client_capabilities(&mut params["capabilities"]);
}
if server.name == "gopls" {
params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] });
}
@@ -1556,7 +1560,6 @@ impl LspManager {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
@@ -1785,7 +1788,6 @@ impl LspManager {
.collect();
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locations);
Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
}
.await;
@@ -1853,7 +1855,6 @@ impl LspManager {
locs.retain(|l| !declarations.contains(l));
}
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
self.note_arduino_sources(&t.server, &locs);
acc.locations.extend(locs);
acc.incomplete = acc.incomplete.or(w.incomplete);
}
@@ -2057,8 +2058,7 @@ impl LspManager {
.unwrap_or_default()
.into_iter()
.map(|a| {
let mut action =
convert::code_action(&conv, &snap.path, a, t.server.id, &t.server.name);
let mut action = convert::code_action(&conv, a, t.server.id);
self.tag_injected_origin(doc, &mut action.data);
self.tag_injected_command(doc, action.server, &mut action.command);
action
@@ -2924,66 +2924,15 @@ impl LspManager {
}
fn origin_server(&self, uri: &str) -> Option<Arc<Server>> {
let id = *self.virtual_origins.lock().get(uri)?;
self.servers.lock().iter().find(|s| s.id == id).cloned()
}
/// The binary archive behind a library document, never its displayed tab title.
pub fn library_archive(uri: &str) -> Option<PathBuf> {
if virtual_doc::scheme(uri) == "jar" {
return library_source::parse(uri).map(|e| e.jar);
}
virtual_doc::description(uri).filter(|p| p.ends_with(".jar") && Path::new(p).is_absolute()).map(PathBuf::from)
}
/// Open an attached archive at the declaration under the decompiled editor's caret.
pub async fn library_source_location(
&self,
uri: &str,
sources: PathBuf,
pos: Pos,
cancel: &CancellationToken,
) -> IdeResult<Option<Location>> {
let previous = tokio::select! {
result = self.virtual_document(uri) => result?,
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
};
let name = previous
.text
.lines()
.nth(pos.line as usize)
.and_then(|line| library_source::identifier_at(line, pos.col))
.map(str::to_owned);
let original = uri.to_owned();
let archive = sources.clone();
let found = tokio::task::spawn_blocking(move || {
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
Location { path: uri.clone().into(), uri: Some(uri), range }
let id = self.virtual_origins.lock().get(uri).copied();
self.servers
.lock()
.iter()
.find(|s| {
Some(s.id) == id
|| s.effective_config().target.as_ref().is_some_and(|t| target::external_path(t, uri).is_some())
})
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if found.is_some() {
if virtual_doc::scheme(uri) == "jdt" {
let result = self
.jdtls_command_cancellable(
&self.project_root,
"java.project.updateSourceAttachment",
vec![json!({ "classFileUri": uri, "attributes": { "sourceAttachmentPath": sources } })],
cancel,
)
.await?;
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
return Err(IdeError::unavailable(error));
}
}
self.virtual_docs.lock().remove(uri);
}
Ok(found)
.cloned()
}
/// Contents of a virtual document, from the server that owns `uri`:
@@ -2992,6 +2941,29 @@ impl LspManager {
/// is read from its archive, a `jar:` class shown as its library source
/// (see [`library_source`]) or else decompiled by the server. Cached.
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
if uri.starts_with("openidea-target:") {
let server = self
.origin_server(uri)
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
let target = server
.effective_config()
.target
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
let path = target::external_path(&target, uri)
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
let read = self
.target_read_hook
.read()
.clone()
.ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
let languages = server.languages();
let title = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned());
let description = Some(path.clone());
let text = tokio::task::spawn_blocking(move || read(&target, &path))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))??;
return Ok(self.store_virtual(uri, text, title, description, &languages, Some(server.id)));
}
if let Some(d) = self.virtual_docs.lock().get(uri) {
return Ok(d.clone());
}
@@ -3146,6 +3118,11 @@ impl LspManager {
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
/// so edits flow through `ide-workspace::Documents`.
pub fn set_target_hooks(&self, configure: Box<TargetConfigFn>, read: Box<TargetReadFn>) {
*self.target_hook.write() = Some(Arc::from(configure));
*self.target_read_hook.write() = Some(Arc::from(read));
}
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
*self.config_hook.write() = Some(Arc::from(hook));
}
@@ -3166,9 +3143,23 @@ impl LspManager {
cfg.launch_error = Some("Project is in Restricted Mode".into());
return cfg;
}
if let Some(hook) = self.config_hook.read().clone() {
let in_target = self.target_hook.read().clone().is_some_and(|hook| {
server
.languages()
.iter()
.filter(|language| {
self.language_servers(language)
.iter()
.any(|c| c.name == server.name && c.target_key == server.config.target_key)
})
.any(|language| hook(language, &mut cfg))
});
if !in_target && let Some(hook) = self.config_hook.read().clone() {
hook(&mut cfg, &server.root);
}
if let Some(target) = &mut cfg.target {
target.origin = Some(server.id);
}
cfg
}
@@ -3242,6 +3233,11 @@ impl LspManager {
}
continue;
}
if next.target != current.target {
for doc in self.docs.lock().values_mut().filter(|d| d.uri.as_str().starts_with("openidea-target:")) {
doc.servers.retain(|a| a.server.id != s.id);
}
}
match s.status() {
ide_core::event::ServerStatus::Ready
| ide_core::event::ServerStatus::Busy
@@ -3262,7 +3258,7 @@ impl LspManager {
}
}
self.attach_fallbacks();
self.detach_shadowed_fallbacks().await;
self.detach_shadowed_servers().await;
self.injection_providers_changed();
}
@@ -3299,9 +3295,9 @@ impl LspManager {
}
}
/// A server enabled again shadows the fallbacks attached in its place: they
/// leave the open documents, and stop once no document uses them.
async fn detach_shadowed_fallbacks(&self) {
/// Detaches fallbacks shadowed by enabled servers and instances whose target
/// no longer matches the document's language. Stop them once unused.
async fn detach_shadowed_servers(&self) {
let open: Vec<(DocId, LanguageId, PathBuf)> =
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
let mut shadowed: Vec<(DocId, Vec<ide_core::ServerId>)> = Vec::new();
@@ -3316,7 +3312,9 @@ impl LspManager {
.filter(|a| {
!wanted.iter().any(|w| w.id == a.server.id)
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|| (a.server.name == "clangd" && wanted.iter().any(|s| s.name == arduino::SERVER)))
|| !configs
.iter()
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
})
.map(|a| a.server.id)
.collect();
@@ -3372,7 +3370,11 @@ impl LspManager {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
let unlisted: Vec<Arc<Server>> = servers
.into_iter()
.filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name)))
.filter(|s| {
!s.languages().iter().any(|l| {
self.language_servers(l).iter().any(|c| c.name == s.name && c.target_key == s.config.target_key)
})
})
.collect();
if unlisted.is_empty() {
return;
+2 -59
View File
@@ -79,27 +79,6 @@ pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
None
}
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
let jar = if uri.starts_with("jdt://contents/") {
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
let parts: Vec<_> = path.split('/').collect();
let [_, package, file] = parts.as_slice() else { return None };
let file = percent_decode_str(file).decode_utf8().ok()?;
let name = file.strip_suffix(".class").or_else(|| file.strip_suffix(".java"))?;
let package = percent_decode_str(package).decode_utf8().ok()?.replace('.', "/");
JarEntry {
prefix: "jar:file://".into(),
jar: sources.to_owned(),
entry: if package.is_empty() { format!("{name}.class") } else { format!("{package}/{name}.class") },
}
} else {
parse(uri)?
};
let class = jar.entry.strip_suffix(".class")?;
let (entry, text) = source_entry(sources, class)?;
Some((jar.uri_of(sources, &entry), text))
}
/// Sources jars of the artifact `jar`: `<name>-sources.jar` next to it (Maven
/// repository), or in a sibling hash folder (Gradle `files-2.1/<g>/<a>/<v>/<hash>/`).
fn sources_jars(jar: &Path) -> Vec<PathBuf> {
@@ -160,20 +139,7 @@ pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
const KEYWORDS: [&str; 9] = ["fun", "class", "interface", "object", "val", "var", "typealias", "enum", "record"];
let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut fallback = None;
let mut block_comment = false;
for (line_no, line) in text.lines().enumerate() {
let trimmed = line.trim_start();
if block_comment {
block_comment = !trimmed.contains("*/");
continue;
}
if trimmed.starts_with("/*") {
block_comment = !trimmed.contains("*/");
continue;
}
if trimmed.starts_with("//") || trimmed.starts_with('*') {
continue;
}
let mut from = 0;
while let Some(i) = line[from..].find(name) {
let start = from + i;
@@ -195,11 +161,8 @@ pub(crate) fn declaration_range(text: &str, name: &str) -> Range {
if words.iter().any(|w| KEYWORDS.contains(w)) {
return range;
}
let java_member = after == Some('(')
&& !head.trim_end().ends_with('.')
&& !head.trim().is_empty()
&& !head.contains(['=', '('])
&& !words.iter().any(|w| matches!(*w, "return" | "throw" | "new" | "yield" | "assert"));
let java_member =
after == Some('(') && !head.trim_end().ends_with('.') && !head.trim().is_empty() && !head.contains('=');
if java_member && fallback.is_none() {
fallback = Some(range);
}
@@ -264,24 +227,6 @@ mod tests {
assert_eq!(parse("jar:///no/entry.jar"), None);
}
#[test]
fn opens_selected_archives_for_jdt_and_jar_classes() {
let tmp = tempfile::tempdir().unwrap();
let sources = tmp.path().join("library sources.zip");
jar(&sources, &[("p/Lib.java", "package p; public class Lib { class Inner {} }")]);
for uri in [
"jdt://contents/lib-1.jar/p/Lib.class?=project/escaped-jar-path",
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
] {
let (uri, text) = source_in_archive(uri, &sources).unwrap();
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
assert!(text.contains("class Lib"));
}
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
}
#[test]
fn finds_the_source_of_each_kind_of_class_in_maven_and_gradle_caches() {
let tmp = tempfile::tempdir().unwrap();
@@ -340,8 +285,6 @@ mod tests {
assert_eq!(at(java, "run"), (3, 49, 52), "the member, not the call in its body");
assert_eq!(at("val x = 1", "missing"), (0, 0, 0));
assert_eq!(at("// é\nfun ü() {}", "ü"), (1, 4, 5), "UTF-16 columns");
let java = "class Lib {\n boolean caller(String s) { return isBlank(s); }\n /**\n * Changed signature from isBlank(String) to isBlank(CharSequence).\n */\n public static boolean isBlank(CharSequence cs) { return false; }\n}\n";
assert_eq!(at(java, "isBlank"), (5, 26, 33), "the declaration, not a call or Javadoc signature");
}
#[test]
+6
View File
@@ -407,6 +407,12 @@ impl Server {
message.push_str(":\n");
message.push_str(&tail);
}
if cfg.target.is_some() {
message.push_str(&format!(
"\nargv: {:?}",
std::iter::once(&cfg.command).chain(cfg.args.iter()).collect::<Vec<_>>()
));
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
"\nRestarting…"
+95
View File
@@ -0,0 +1,95 @@
//! Protocol mapping for a stdio server inside a plugin target.
use ide_core::path_mapping::{Direction, file_path, file_uri, rewrite_strings};
use ide_jsonrpc::MessageTransform;
use ide_language::ServerTarget;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde_json::Value;
pub(crate) fn virtual_prefix(target: &ServerTarget) -> String {
format!(
"openidea-target://{}/{}/",
utf8_percent_encode(&target.id, NON_ALPHANUMERIC),
target.origin.map_or(0, |id| id.0)
)
}
pub(crate) fn external_path(target: &ServerTarget, uri: &str) -> Option<String> {
let path = file_path(&format!("file:///{}", uri.strip_prefix(&virtual_prefix(target))?))?;
(path.starts_with('/')
&& !path.split('/').any(|s| s == ".." || s == ".")
&& target.mapping.map(&file_uri(&path), Direction::ToHost).is_none())
.then_some(path)
}
fn suffix(uri: &str) -> &str {
uri.find(['?', '#']).map(|i| &uri[i..]).unwrap_or("")
}
pub(crate) struct TargetTransform(pub ServerTarget);
impl MessageTransform for TargetTransform {
fn outgoing(&self, value: &mut Value) {
rewrite_strings(value, &|s| {
// A container library path may happen to have the host root's spelling.
external_path(&self.0, s)
.map(|p| format!("{}{}", file_uri(&p), suffix(s)))
.or_else(|| self.0.mapping.map(s, Direction::ToTarget))
});
}
fn incoming(&self, value: &mut Value) {
rewrite_strings(value, &|s| {
if let Some(mapped) = self.0.mapping.map(s, Direction::ToHost) {
return Some(mapped);
}
let path = file_path(s)?;
if !path.starts_with('/') || path.split('/').any(|p| p == ".." || p == ".") {
return None;
}
let uri = file_uri(&path);
Some(format!("{}{}{}", virtual_prefix(&self.0), uri.strip_prefix("file:///")?, suffix(s)))
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::path_mapping::PathMapping;
use serde_json::json;
#[test]
fn external_uris_are_virtual_but_plain_outside_paths_and_opaque_values_stay_intact() {
let target = ServerTarget {
id: "plugin-target:example".into(),
origin: Some(ide_core::ServerId(7)),
project_root: "/host/project".into(),
container_root: "/container".into(),
mapping: PathMapping::new("/host/project", "/container"),
};
let mapper = TargetTransform(target.clone());
let mut value = json!({"uri":"file:///container/a%20b.php", "relatedInformation":[{"location":{"uri":"file:///usr/lib/a%20b.php"}}],
"data":{"uri":"file:///container/a.php"}, "command":{"arguments":["/container/a.php"]}, "path":"/usr/lib/a.php", "link":"file:///usr/lib/a.php#L12", "alias":"file:///host/project/std.php"});
mapper.incoming(&mut value);
assert_eq!(value["uri"], "file:///host/project/a%20b.php");
let external = value["relatedInformation"][0]["location"]["uri"].as_str().unwrap();
assert!(external.starts_with("openidea-target:"));
assert_eq!(external_path(&target, external).unwrap(), "/usr/lib/a b.php");
let other = ServerTarget { origin: Some(ide_core::ServerId(8)), ..target.clone() };
assert!(external_path(&other, external).is_none());
assert!(value["link"].as_str().unwrap().ends_with("#L12"));
assert_eq!(value["path"], "/usr/lib/a.php");
assert_eq!(value["data"]["uri"], "file:///container/a.php");
assert_eq!(value["command"]["arguments"][0], "/container/a.php");
mapper.outgoing(&mut value);
assert_eq!(value["uri"], "file:///container/a%20b.php");
assert_eq!(value["relatedInformation"][0]["location"]["uri"], "file:///usr/lib/a%20b.php");
assert_eq!(value["link"], "file:///usr/lib/a.php#L12");
assert_eq!(value["alias"], "file:///host/project/std.php");
let mut configuration =
json!({"result":[{"path":"/host/project/vendor","uris":["file:///host/project/vendor/a.php"]}]});
mapper.outgoing(&mut configuration);
assert_eq!(configuration["result"][0]["path"], "/container/vendor");
assert_eq!(configuration["result"][0]["uris"][0], "file:///container/vendor/a.php");
}
}
+2 -14
View File
@@ -2,7 +2,7 @@
use ide_core::event::ServerStatus;
use ide_core::settings::ExternalFormatter;
use ide_core::{Diagnostic, Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit};
use ide_core::{Location, Range, ServerId, SymbolKind, TextEdit, WorkspaceEdit};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -49,11 +49,7 @@ pub struct CompletionItem {
pub label: String,
/// e.g. `(req: CreateOrderRequest)` shown right after the label.
pub label_detail: Option<String>,
/// `labelDetails.description`: a return type or module path, shown right-aligned. Servers
/// such as JDT.LS also send the whole signature as `detail`, which would repeat the label.
pub label_description: Option<String>,
/// The signature or type when the server sent no `label_description`; the documentation panel
/// shows it either way.
/// e.g. return type, shown right-aligned.
pub detail: Option<String>,
pub kind: Option<CompletionKind>,
pub documentation: Option<Markup>,
@@ -197,14 +193,6 @@ pub struct InlineCompletionItem {
#[serde(rename_all = "camelCase")]
pub struct CodeAction {
pub title: String,
/// Diagnostics this action fixes, in IDE coordinates.
#[serde(default)]
#[specta(optional)]
pub diagnostics: Vec<Diagnostic>,
/// Original server diagnostics, including encoding and data, for codeAction/resolve.
#[serde(default)]
#[specta(optional)]
pub original_diagnostics: Option<String>,
/// LSP kind, e.g. `quickfix`, `refactor.extract`, `source.organizeImports`.
pub kind: Option<String>,
pub is_preferred: bool,
+122 -12
View File
@@ -268,6 +268,8 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
}
}
@@ -829,6 +831,52 @@ async fn java_sharing_preserves_other_servers_and_marker_scoped_fallbacks() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_sharing_keeps_target_instances_separate_and_preserves_them_on_toggle() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let shared = Arc::new(std::sync::atomic::AtomicBool::new(true));
h.mgr.set_jdtls_shared_hook(Box::new({
let shared = shared.clone();
move || shared.load(Ordering::SeqCst)
}));
h.mgr.set_servers_hook(Box::new(|_, configs| {
let mut target = configs[0].clone();
target.target_key = Some("container".into());
target
.initialization_options
.as_mut()
.unwrap()
.as_table_mut()
.unwrap()
.insert("selectedTarget".into(), toml::Value::Boolean(true));
vec![configs[0].clone(), target]
}));
h.open("Main.java", "class Main {}");
for path in java_siblings(&h) {
h.mgr.did_open(DocId::next(), &path, &h.lang, 1, "class Main {}");
}
let state = h.launcher.state("jdtls");
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 4).await;
let initial = state.requested("initialize");
assert_eq!(initial.len(), 4);
let targets: Vec<_> = initial.iter().filter(|v| v["initializationOptions"]["selectedTarget"] == true).collect();
assert_eq!(targets.len(), 3, "target roots retain their marker-based identity");
assert!(targets.iter().all(|v| v["initializationOptions"].get("workspaceFolders").is_none()));
let local = initial.iter().find(|v| v["initializationOptions"]["selectedTarget"] != true).unwrap();
assert_eq!(local["rootUri"], uri_of(&h.root));
assert_eq!(local["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root)]));
let old: HashSet<_> = h.mgr.servers().into_iter().map(|s| s.id).collect();
shared.store(false, Ordering::SeqCst);
h.mgr.reconfigure().await;
eventually(|| h.mgr.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 6).await;
assert_eq!(
h.mgr.servers().iter().filter(|s| old.contains(&s.id)).count(),
3,
"local sharing does not retire targets"
);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn virtual_java_documents_opened_during_a_toggle_attach_to_the_successor() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
@@ -1253,7 +1301,7 @@ async fn completion_mapping_merge_and_resolve() {
"isIncomplete": false,
"itemDefaults": { "data": { "from": "defaults" } },
"items": [{
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()", "description": "u8" },
"label": "bar", "kind": 2, "detail": "fn() -> u8", "labelDetails": { "detail": "()" },
"textEdit": { "newText": "bar()$0", "insert": {"start":{"line":0,"character":12},"end":{"line":0,"character":14}},
"replace": {"start":{"line":0,"character":12},"end":{"line":0,"character":15}} },
"insertTextFormat": 2, "sortText": "0001"
@@ -1274,7 +1322,6 @@ async fn completion_mapping_merge_and_resolve() {
assert_eq!(bar.insert_text, "bar()$0");
assert!(bar.is_snippet);
assert_eq!(bar.label_detail.as_deref(), Some("()"));
assert_eq!(bar.label_description.as_deref(), Some("u8"));
assert_eq!(bar.replace_range, Some(rng(0, 12, 0, 14)));
assert_eq!(bar.replace_range_tab, Some(rng(0, 12, 0, 15)));
assert_eq!(bar.data, Some(json!({ "from": "defaults" })));
@@ -1290,13 +1337,9 @@ async fn completion_mapping_merge_and_resolve() {
assert_eq!(resolved.documentation.as_ref().unwrap().value, "**docs**");
assert_eq!(resolved.additional_edits[0].new_text, "use foo;\n");
assert_eq!(resolved.insert_text, "bar()$0", "fields absent from the resolve reply are kept");
assert_eq!(resolved.label_detail.as_deref(), Some("()"));
assert_eq!(resolved.label_description.as_deref(), Some("u8"));
assert_eq!(resolved.detail.as_deref(), Some("fn() -> u8"));
assert_eq!(resolved.replace_range_tab, Some(rng(0, 12, 0, 15)));
let sent = &a.requested("completionItem/resolve")[0];
assert_eq!(sent["data"], json!({ "from": "defaults" }));
assert_eq!(sent["labelDetails"], json!({ "detail": "()", "description": "u8" }));
assert_eq!(sent["textEdit"]["replace"]["end"]["character"], 15);
// Server b has no resolve support: the item comes back unchanged.
@@ -1401,7 +1444,7 @@ async fn diagnostics_become_events_and_feed_code_actions() {
s.respond(
"textDocument/codeAction",
json!([
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 }, "diagnostics": [diag(0)] },
{ "title": "Remove", "kind": "quickfix", "isPreferred": true, "data": { "id": 7 } },
{ "title": "Run", "command": "mock.run", "arguments": [1] }
]),
);
@@ -1416,9 +1459,6 @@ async fn diagnostics_become_events_and_feed_code_actions() {
assert_eq!(actions.len(), 2);
assert_eq!(actions[0].kind.as_deref(), Some("quickfix"));
assert!(actions[0].is_preferred);
assert_eq!(actions[0].diagnostics[0].range, rng(0, 4, 0, 10));
assert_eq!(actions[0].diagnostics[0].message, "unused variable");
assert!(actions[1].diagnostics.is_empty());
assert_eq!(actions[1].command.as_ref().unwrap().command, "mock.run");
let req = &s.requested("textDocument/codeAction")[0];
assert_eq!(req["context"]["only"], json!(["quickfix"]));
@@ -1428,8 +1468,6 @@ async fn diagnostics_become_events_and_feed_code_actions() {
let resolved = h.mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
assert_eq!(s.requested("codeAction/resolve")[0]["diagnostics"], json!([diag(0)]));
assert_eq!(resolved.diagnostics, actions[0].diagnostics);
let edit = resolved.edit.unwrap();
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0))
@@ -5943,3 +5981,75 @@ async fn missing_injected_server_uses_the_normal_single_install_notification() {
h.mgr.did_close(host);
h.mgr.shutdown_all().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn languages_sharing_a_server_keep_independent_target_choices() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let language = |id: &str| {
let mut lang: LanguageConfig = toml::from_str(&format!("id='{id}'\nname='{id}'\nextensions=['{id}']")).unwrap();
lang.language_servers = vec![server_cfg("shared")];
lang
};
let registry = Arc::new(LanguageRegistry::from_configs(vec![language("one"), language("two")]));
struct ScopedLauncher(Arc<MockLauncher>);
impl Launcher for ScopedLauncher {
fn launch(
&self,
config: &LanguageServerConfig,
root: &Path,
handler: Arc<dyn Handler>,
stderr: Box<dyn Fn(String) + Send + Sync>,
) -> Result<Peer, LaunchError> {
let mut config = config.clone();
if let Some(key) = &config.target_key {
config.name = format!("{}@{key}", config.name);
}
self.0.launch(&config, root, handler, stderr)
}
}
let launcher = Arc::new(MockLauncher::default());
for name in ["shared", "shared@container"] {
*launcher.state(name).caps.lock() = json!({"textDocumentSync":1,"hoverProvider":true});
}
let manager = LspManager::with_launcher(
root.clone(),
registry,
Arc::new(ide_core::NullSink),
Arc::new(ScopedLauncher(launcher.clone())),
);
let selected = Arc::new(std::sync::atomic::AtomicBool::new(false));
let selected_hook = selected.clone();
manager.set_servers_hook(Box::new(move |lang, defined| {
let mut configs = defined.to_vec();
if lang.as_str() == "one" && selected_hook.load(Ordering::Acquire) {
configs[0].target_key = Some("container".into());
}
configs
}));
let one = DocId::next();
let two = DocId::next();
manager.did_open(one, &root.join("a.one"), &LanguageId::new("one"), 1, "one");
manager.did_open(two, &root.join("b.two"), &LanguageId::new("two"), 1, "two");
eventually(|| manager.servers().iter().any(|s| s.status == ServerStatus::Ready)).await;
assert_eq!(manager.servers().len(), 1);
let host = manager.servers()[0].id;
selected.store(true, Ordering::Release);
manager.reconfigure().await;
eventually(|| manager.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 2).await;
assert!(manager.servers().iter().any(|s| s.id == host));
// Each document has one server. Hover must not send to both after splitting.
let state = launcher.state("shared");
state.requests.lock().clear();
let target_state = launcher.state("shared@container");
target_state.requests.lock().clear();
let _ = manager.hover(one, Pos::new(0, 0), CancellationToken::new()).await;
let _ = manager.hover(two, Pos::new(0, 0), CancellationToken::new()).await;
assert_eq!(state.requested("textDocument/hover").len(), 1);
assert_eq!(target_state.requested("textDocument/hover").len(), 1);
selected.store(false, Ordering::Release);
manager.reconfigure().await;
assert_eq!(manager.servers().len(), 1);
assert_eq!(manager.servers()[0].id, host);
manager.shutdown_all().await;
}
+6
View File
@@ -425,6 +425,8 @@ async fn missing_binary_is_reported_with_install_hint() {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
};
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
let path = dir.path().join("a.nope");
@@ -696,6 +698,8 @@ async fn typescript_language_server_end_to_end() {
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
};
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
let doc = DocId::next();
@@ -747,6 +751,8 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
tsserver_project_extras: Default::default(),
resync_on_configuration: false,
pinned: None,
target: None,
target_key: None,
}
}
Loaded 100 of 957 files, more files were not shown because too many files have changed in this diff. Show more