дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-10 19:55:40 +02:00
Порівняти коміти
2
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
b7b9ee04f1 | ||
|
|
0fb8955269 |
No files matched your search
@@ -3,6 +3,3 @@ contact_links:
|
||||
- name: Download the latest release
|
||||
url: https://github.com/l0ng-ai/openidea/releases/latest
|
||||
about: Many problems are already fixed. Please check the latest version before reporting.
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/l0ng-ai/openidea/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue.
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release-build.yml):
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
|
||||
@@ -47,7 +47,6 @@ env:
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
INTELEPHENSE: 1.18.5
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
@@ -55,9 +54,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
|
||||
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
|
||||
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,12 +74,6 @@ jobs:
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
|
||||
- if: matrix.language == 'c' || matrix.language == 'cpp'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gdb
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
@@ -116,13 +108,6 @@ jobs:
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
|
||||
- if: matrix.language == 'php'
|
||||
uses: shivammathur/setup-php@v2
|
||||
with: { php-version: "8.5", coverage: xdebug, tools: composer }
|
||||
- if: matrix.language == 'php'
|
||||
run: npm install --global "intelephense@$INTELEPHENSE"
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
name: Nightly
|
||||
|
||||
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
|
||||
# version, so remote development finds the server of that version. The `nightly` release holds
|
||||
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
|
||||
# nothing newer stops there. Only the latest builds are kept.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2,14 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Build even if nothing is newer than the last nightly"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
if: github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.sha }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- id: commit
|
||||
env:
|
||||
FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
|
||||
--json headSha --jq '.[0].headSha // ""')"
|
||||
if [ -z "$sha" ]; then
|
||||
echo "::notice::no main commit has passed CI"
|
||||
exit 0
|
||||
fi
|
||||
# No `nightly` tag yet (the first run): compare fails and the build goes on.
|
||||
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
|
||||
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
|
||||
echo "::notice::$sha is not newer than the last nightly"
|
||||
exit 0
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { ref: "${{ steps.commit.outputs.sha }}" }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.commit.outputs.sha != ''
|
||||
with: { node-version: 22 }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.commit.outputs.sha != ''
|
||||
- id: release
|
||||
if: steps.commit.outputs.sha != ''
|
||||
env:
|
||||
SHA: ${{ steps.commit.outputs.sha }}
|
||||
run: |
|
||||
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
|
||||
tag="v$version"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -am "chore: $tag"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
|
||||
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag != ''
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ needs.prepare.outputs.tag }}
|
||||
release_id: ${{ needs.prepare.outputs.release_id }}
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [prepare, binaries]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
SHA: ${{ needs.prepare.outputs.sha }}
|
||||
KEEP: 14
|
||||
steps:
|
||||
- name: Publish the build
|
||||
run: gh release edit "$TAG" --tag "$TAG" --draft=false
|
||||
- name: Point the updater at it
|
||||
run: |
|
||||
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
|
||||
gh release view nightly >/dev/null 2>&1 ||
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
|
||||
--notes "The update manifest of the nightly builds, and the commit last built."
|
||||
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
|
||||
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
|
||||
# Failed builds' drafts go too, once they are this old.
|
||||
- name: Delete old builds
|
||||
run: |
|
||||
gh release list --limit 200 --json tagName,createdAt --jq \
|
||||
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
|
||||
while read -r tag; do
|
||||
gh release delete "$tag" --cleanup-tag --yes
|
||||
done
|
||||
@@ -1,262 +0,0 @@
|
||||
name: Release build
|
||||
|
||||
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
|
||||
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
release_id: { type: string, required: true }
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
plugin-keys:
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
keys: ${{ steps.keys.outputs.keys }}
|
||||
steps:
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: plugin-keys
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
|
||||
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
|
||||
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ inputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
# Release assets have no spaces: GitHub stored the first upload with dots.
|
||||
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
|
||||
cp "$dmg" "$asset"
|
||||
gh release upload "$TAG" "$asset" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
permissions:
|
||||
contents: write
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ inputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
|
||||
const nightly = tag.includes("-nightly.");
|
||||
const windows = (arch, msi, nsis) => nightly
|
||||
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
|
||||
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
|
||||
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ["v*", "!v*-nightly.*"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event.inputs.tag || github.ref_name }}
|
||||
@@ -23,12 +23,10 @@ env:
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
create-release:
|
||||
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
@@ -49,6 +47,20 @@ jobs:
|
||||
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
@@ -62,26 +74,161 @@ jobs:
|
||||
core.setOutput("id", existing.id);
|
||||
return;
|
||||
}
|
||||
// Nightly builds are prereleases: the notes span stable release to stable release.
|
||||
const previous = releases.find((r) => !r.draft && !r.prerelease);
|
||||
const { data: notes } = await github.rest.repos.generateReleaseNotes({
|
||||
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
|
||||
});
|
||||
const { data } = await github.rest.repos.createRelease({
|
||||
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
|
||||
draft: true, prerelease: tag.includes("-"), body: notes.body,
|
||||
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
|
||||
});
|
||||
core.setOutput("id", data.id);
|
||||
|
||||
binaries:
|
||||
permissions:
|
||||
contents: write
|
||||
build:
|
||||
needs: create-release
|
||||
uses: ./.github/workflows/release-build.yml
|
||||
with:
|
||||
tag: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
secrets: inherit
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
- name: macOS x64
|
||||
os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "${{ matrix.target }}" }
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Signing is optional: only secrets that are set get exported, so forks and
|
||||
# unconfigured repos produce unsigned builds instead of failing.
|
||||
- name: Signing env
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
|
||||
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
|
||||
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
|
||||
done
|
||||
true
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
tagName: ${{ env.TAG }}
|
||||
args: --target ${{ matrix.target }}
|
||||
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
|
||||
uploadUpdaterJson: false
|
||||
# tauri-action notarizes the .app but not the DMG around it; notarize and
|
||||
# staple the DMG too, then replace the uploaded asset.
|
||||
- name: Notarize DMG
|
||||
if: runner.os == 'macOS' && env.APPLE_ID != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
|
||||
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" --wait
|
||||
xcrun stapler staple "$dmg"
|
||||
gh release upload "$TAG" "$dmg" --clobber
|
||||
done
|
||||
# Portable Windows builds (#1857): on Windows Tauri embeds the
|
||||
# frontend in the exe and WebView2 is a system component, so zipping
|
||||
# the raw exe gives an unpack-and-run alternative to the installers.
|
||||
- name: Portable zip
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$target = "${{ matrix.target }}"
|
||||
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
|
||||
$version = $env:TAG -replace '^v', ''
|
||||
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
|
||||
$dir = "target/$target/release"
|
||||
$files = @("$dir/openidea.exe")
|
||||
if (Test-Path "$dir/WebView2Loader.dll") {
|
||||
$files += "$dir/WebView2Loader.dll"
|
||||
}
|
||||
Compress-Archive -Path $files -DestinationPath $asset -Force
|
||||
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
|
||||
gh release upload $env:TAG $asset "$asset.sha256" --clobber
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
needs: create-release
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
# The official plugins, signed, and the marketplace index released apps read
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
@@ -93,8 +240,6 @@ jobs:
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -158,10 +303,70 @@ jobs:
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
updater-manifest:
|
||||
needs: [create-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const release_id = Number(process.env.RELEASE_ID);
|
||||
const tag = process.env.TAG;
|
||||
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
|
||||
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
|
||||
if (sigs.length === 0) {
|
||||
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
|
||||
return;
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
const platforms = {};
|
||||
for (const sig of sigs) {
|
||||
const bundle = sig.name.slice(0, -".sig".length);
|
||||
const kind = kinds.find(([re]) => re.test(bundle));
|
||||
if (!kind) continue;
|
||||
const { data } = await github.rest.repos.getReleaseAsset({
|
||||
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
|
||||
});
|
||||
const signature = Buffer.from(data).toString("utf8").trim();
|
||||
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
|
||||
for (const key of kind[1]) platforms[key] = { signature, url };
|
||||
}
|
||||
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
|
||||
if (missing.length) {
|
||||
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
|
||||
const old = assets.find((a) => a.name === "latest.json");
|
||||
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner, repo, release_id, name: "latest.json",
|
||||
data: JSON.stringify(manifest, null, 2),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [create-release, binaries, plugins]
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Створено
+85
-48
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1578,6 +1578,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1598,6 +1608,20 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1607,7 +1631,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1620,10 +1644,21 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -2650,7 +2685,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4197,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",
|
||||
@@ -4241,6 +4274,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4273,6 +4307,8 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
@@ -4298,7 +4334,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracledb",
|
||||
"oracle",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4335,7 +4371,6 @@ version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4421,6 +4456,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
@@ -5717,7 +5753,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6453,6 +6489,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6566,24 +6611,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracledb"
|
||||
version = "26.0.0-beta.5"
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base16ct",
|
||||
"base64ct",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"pbkdf2",
|
||||
"pkcs8",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"sha2 0.11.1",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8870,6 +8920,12 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8906,6 +8962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9128,7 +9185,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
@@ -10668,15 +10725,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -10686,15 +10734,6 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11278,7 +11317,6 @@ dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -12116,15 +12154,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",
|
||||
|
||||
+2
-3
@@ -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"
|
||||
@@ -106,8 +106,7 @@ rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
|
||||
+10
-6
@@ -12,8 +12,6 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
|
||||
·
|
||||
<a href="https://openidea.sh">Docs</a>
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security problems in public issues, pull requests or discussions.
|
||||
|
||||
Report them privately through GitHub:
|
||||
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
|
||||
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
|
||||
the report.
|
||||
|
||||
Include what you can of:
|
||||
|
||||
- the affected version, platform, and whether remote development (`openidea-server`)
|
||||
or plugins are involved;
|
||||
- steps to reproduce, or a proof of concept;
|
||||
- the impact you expect, and any fix you suggest.
|
||||
|
||||
We will acknowledge the report, keep you updated in the advisory, and agree on a
|
||||
disclosure date with you. Fixes are released as a new version, and the advisory is
|
||||
published once users can update.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes go into the latest release. Please check that the problem still occurs
|
||||
there before reporting.
|
||||
@@ -1,12 +1,5 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Flutter and Vite project icons
|
||||
|
||||
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
|
||||
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
|
||||
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
@@ -269,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
|
||||
@@ -297,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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
@@ -1722,7 +1716,7 @@ impl Task {
|
||||
format!("The agent process exited:\n{}", tail.join("\n"))
|
||||
}
|
||||
}
|
||||
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
|
||||
RpcError::Server { message, .. } => message,
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -2180,24 +2174,6 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
|
||||
.then_some(None)
|
||||
}
|
||||
|
||||
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
|
||||
/// generic codes such as "Internal error" say nothing without it.
|
||||
fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
let details = match data {
|
||||
Some(Value::String(text)) => text.as_str(),
|
||||
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
|
||||
None => "",
|
||||
}
|
||||
.trim();
|
||||
if details.is_empty() || message.contains(details) {
|
||||
message
|
||||
} else if message.trim().is_empty() {
|
||||
details.to_owned()
|
||||
} else {
|
||||
format!("{message}: {details}")
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
@@ -2340,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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -2532,53 +2532,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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_keep_the_agents_details() {
|
||||
let details = json!({ "details": "spawn claude ENOENT" });
|
||||
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
|
||||
assert_eq!(
|
||||
with_details("Internal error".into(), Some(&json!("model not found"))),
|
||||
"Internal error: model not found"
|
||||
);
|
||||
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
|
||||
assert_eq!(with_details("Internal error".into(), None), "Internal error");
|
||||
}
|
||||
@@ -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 ¤t.files {
|
||||
let Some(current_stamp) = current_stamp else { continue };
|
||||
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
|
||||
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
|
||||
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
|
||||
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
|
||||
edits.push(path.clone());
|
||||
}
|
||||
}
|
||||
edits.sort();
|
||||
edits
|
||||
}
|
||||
}
|
||||
|
||||
#[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(¤t)
|
||||
}
|
||||
// Same length and mtime, different content: the builder would skip it.
|
||||
assert_eq!(
|
||||
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
|
||||
vec![PathBuf::from("a.java")]
|
||||
);
|
||||
// A normal edit bumps the mtime: the builder already sees it.
|
||||
let mut touched = stamp(5, "new").unwrap();
|
||||
touched.1 += std::time::Duration::from_secs(1);
|
||||
assert!(
|
||||
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
|
||||
);
|
||||
// Added, deleted, and hash-less inputs (directories, files outside the
|
||||
// project) carry no comparable content: never reported.
|
||||
assert!(
|
||||
prints(
|
||||
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
|
||||
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_source_is_found_against_a_real_fingerprint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let source = root.join("lib/src/main/kotlin/Channel.kt");
|
||||
let before = fingerprint(root, &model).unwrap();
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
// Same 5-byte length, different content, preserved mtime.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let after = fingerprint(root, &model).unwrap();
|
||||
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
|
||||
// A normal edit (mtime advances) needs no repair.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
|
||||
let normal = fingerprint(root, &model).unwrap();
|
||||
assert!(before.mtime_preserved_edits(&normal).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -499,7 +499,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 +519,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 +545,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 +736,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 +1640,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 +2297,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,
|
||||
|
||||
@@ -3,57 +3,6 @@
|
||||
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
|
||||
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
use std::path::Path;
|
||||
|
||||
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
|
||||
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
|
||||
/// whole project as "No maven project" while CLI Maven reads it fine.
|
||||
pub fn read_text(path: &Path) -> Option<String> {
|
||||
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
|
||||
}
|
||||
|
||||
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
|
||||
/// every single-byte encoding, so the head scans before decoding), then
|
||||
/// UTF-8, then a chardetng guess.
|
||||
pub fn decode_bytes(bytes: &[u8]) -> String {
|
||||
if let Some((enc, len)) = Encoding::for_bom(bytes) {
|
||||
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
|
||||
return text.into_owned();
|
||||
}
|
||||
if let Some(enc) = declared_encoding(bytes) {
|
||||
let (text, _) = enc.decode_without_bom_handling(bytes);
|
||||
return text.into_owned();
|
||||
}
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
return text.to_owned();
|
||||
}
|
||||
let mut detector = chardetng::EncodingDetector::new();
|
||||
detector.feed(bytes, true);
|
||||
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
|
||||
text.into_owned()
|
||||
}
|
||||
|
||||
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
|
||||
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
|
||||
let mut rest = head.split("?>").next().unwrap_or(head);
|
||||
while let Some(i) = rest.find("encoding") {
|
||||
rest = &rest[i + "encoding".len()..];
|
||||
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
|
||||
let value = value.trim_start();
|
||||
let ("e, after) = value.as_bytes().split_first()?;
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
continue;
|
||||
}
|
||||
let after = std::str::from_utf8(after).ok()?;
|
||||
let end = after.find(quote as char)?;
|
||||
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
|
||||
return Some(enc);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Element {
|
||||
pub name: String,
|
||||
@@ -261,19 +210,4 @@ mod tests {
|
||||
assert!(parse("").is_none());
|
||||
assert!(parse("just text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_the_declared_encoding() {
|
||||
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
|
||||
let (bytes, _, _) =
|
||||
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
|
||||
let text = decode_bytes(&bytes);
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
|
||||
// An unknown label falls back to UTF-8 rather than mis-decoding.
|
||||
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
|
||||
// No declaration: plain UTF-8 passes through untouched.
|
||||
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
|
||||
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -73,8 +73,6 @@ pub struct EditorAppearanceSettings {
|
||||
pub whitespace_inner: bool,
|
||||
pub whitespace_trailing: bool,
|
||||
pub indent_guides: bool,
|
||||
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
|
||||
pub rainbow_indent_guides: bool,
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
@@ -97,7 +95,6 @@ impl Default for EditorAppearanceSettings {
|
||||
whitespace_inner: true,
|
||||
whitespace_trailing: true,
|
||||
indent_guides: true,
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
distraction_free_width: 80,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -1273,11 +1174,6 @@ pub struct ProjectStructureSettings {
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
|
||||
pub dependencies: Vec<String>,
|
||||
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
|
||||
/// A member not listed here (or with an empty list) navigates only into itself.
|
||||
pub member_dependencies: BTreeMap<String, Vec<String>>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
@@ -1451,27 +1347,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
|
||||
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
|
||||
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
|
||||
// accepts a key path it finds there.
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
|
||||
for on in [true, false] {
|
||||
let mut settings = Settings::default();
|
||||
settings.vcs.commit.double_click_opens_diff = Some(on);
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
|
||||
}
|
||||
// Explicit `null` reads back as unset.
|
||||
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
|
||||
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_meters_default_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
@@ -1486,22 +1361,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();
|
||||
|
||||
@@ -115,13 +115,6 @@ pub struct CommitSettings {
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
/// Double-click / Enter in the changes view: `None` follows the interface (the
|
||||
/// Commit tool window opens the diff, Local Changes the source file).
|
||||
///
|
||||
/// No `skip_serializing_if`: an unset value must still appear in
|
||||
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
|
||||
#[specta(optional)]
|
||||
pub double_click_opens_diff: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -142,7 +135,6 @@ impl Default for CommitSettings {
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
double_click_opens_diff: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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!(
|
||||
@@ -654,7 +552,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 +583,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 +740,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);
|
||||
|
||||
@@ -71,6 +71,7 @@ pub enum Incoming {
|
||||
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
|
||||
|
||||
struct Inner {
|
||||
mapping: Option<crate::target::TargetMapping>,
|
||||
out: mpsc::UnboundedSender<Vec<u8>>,
|
||||
pending: Mutex<Pending>,
|
||||
seq: AtomicI64,
|
||||
@@ -106,9 +107,22 @@ impl Connection {
|
||||
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
|
||||
/// own output on stdout, decoded) to `on_output`.
|
||||
pub fn start_with_output<R, W>(
|
||||
reader: R,
|
||||
writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start_with_target(reader, writer, on_output, None)
|
||||
}
|
||||
|
||||
pub fn start_with_target<R, W>(
|
||||
reader: R,
|
||||
mut writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
target: Option<crate::target::TargetMapping>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
@@ -117,6 +131,7 @@ impl Connection {
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (in_tx, in_rx) = mpsc::unbounded_channel();
|
||||
let inner = Arc::new(Inner {
|
||||
mapping: target,
|
||||
out: out_tx,
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
seq: AtomicI64::new(1),
|
||||
@@ -187,7 +202,11 @@ impl Connection {
|
||||
if self.is_closed() {
|
||||
return Err(DapError::Closed);
|
||||
}
|
||||
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
let mut value = value.clone();
|
||||
if let Some(mapping) = self.inner.mapping.as_ref() {
|
||||
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
|
||||
}
|
||||
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
|
||||
self.inner.out.send(body).map_err(|_| DapError::Closed)
|
||||
}
|
||||
@@ -246,7 +265,16 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let Some(msg) = parse_message(body) else { return };
|
||||
let mut msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(mapping) = inner.mapping.as_ref() {
|
||||
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
|
||||
}
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
@@ -279,25 +307,6 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
|
||||
}
|
||||
}
|
||||
|
||||
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
|
||||
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
|
||||
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
|
||||
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
|
||||
/// output (UTF-8, else the system code page) before parsing.
|
||||
fn parse_message(body: &[u8]) -> Option<Value> {
|
||||
let error = match serde_json::from_slice(body) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
if std::str::from_utf8(body).is_err()
|
||||
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
tracing::warn!("dap: unparsable message: {error}");
|
||||
None
|
||||
}
|
||||
|
||||
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
|
||||
fn error_message(msg: &Value) -> String {
|
||||
if let Some(err) = msg.pointer("/body/error")
|
||||
@@ -319,22 +328,6 @@ fn error_message(msg: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_output_events_with_non_utf8_text() {
|
||||
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
|
||||
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
|
||||
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
|
||||
body.extend_from_slice(br#"\n"}}"#);
|
||||
let msg = parse_message(&body).expect("parsed");
|
||||
assert_eq!(msg["event"], "output");
|
||||
let output = msg["body"]["output"].as_str().unwrap();
|
||||
assert!(output.ends_with('\n'));
|
||||
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
|
||||
#[cfg(not(windows))]
|
||||
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
|
||||
assert!(parse_message(b"{not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_structured_errors() {
|
||||
let msg =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -700,6 +700,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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:?}"));
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -20,7 +20,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracledb.workspace = true
|
||||
oracle.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
@@ -594,13 +594,6 @@ fn is_identifier(name: &str) -> bool {
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
|
||||
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
|
||||
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
|
||||
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
|
||||
if vendor == "starrocks" { (false, false) } else { (true, true) }
|
||||
}
|
||||
|
||||
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
|
||||
fn mysql_value(v: &str) -> String {
|
||||
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
|
||||
@@ -846,8 +839,6 @@ impl Pool {
|
||||
if let Some(c) = &setup.collation {
|
||||
opts = opts.collation(c);
|
||||
}
|
||||
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
|
||||
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
|
||||
if let Some(n) = setup.statement_cache {
|
||||
opts = opts.statement_cache_capacity(n);
|
||||
}
|
||||
@@ -1042,7 +1033,7 @@ pub enum Held {
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1058,7 +1049,7 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1474,15 +1465,6 @@ mod tests {
|
||||
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
|
||||
// MySQL and its other wire-compatible presets keep sqlx's defaults.
|
||||
for vendor in ["", "mysql", "mariadb", "tidb"] {
|
||||
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
|
||||
}
|
||||
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_properties_become_pragmas() {
|
||||
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
|
||||
|
||||
@@ -57,7 +57,6 @@ impl DbTableKind {
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Package,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
@@ -93,21 +92,12 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// An Oracle package (its procedures and functions are not listed separately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPackageInfo")]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions), packages and sequences.
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
@@ -306,7 +296,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
@@ -863,14 +853,6 @@ impl DbManager {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::packages(driver, schema) {
|
||||
out.packages = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::sequences(driver, schema) {
|
||||
out.sequences = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
@@ -888,29 +870,9 @@ impl DbManager {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
/// The definition of a routine (every overload), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
|
||||
match pool.rows(&q, MAX_META_ROWS).await {
|
||||
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
|
||||
// A spec-only package has no body: the spec alone still opens.
|
||||
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
return Ok(defs
|
||||
.iter()
|
||||
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
|
||||
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
|
||||
let defs: Vec<String> = pool
|
||||
|
||||
+244
-606
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,7 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct DbOptions {
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
|
||||
pub instant_client: String,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
@@ -268,7 +270,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
|
||||
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
@@ -557,7 +559,6 @@ mod tests {
|
||||
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
|
||||
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
|
||||
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Oracle));
|
||||
|
||||
@@ -664,32 +664,6 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
|
||||
/// listed separately, as in IDEA).
|
||||
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The statement reading a package's specification (`body` false) or body as DDL.
|
||||
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
|
||||
if body { "PACKAGE_BODY" } else { "PACKAGE" },
|
||||
quote_literal(driver, name),
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
@@ -989,7 +963,6 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
|
||||
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
|
||||
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
|
||||
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
|
||||
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
@@ -1011,17 +984,6 @@ mod tests {
|
||||
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
|
||||
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
|
||||
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1039,7 +1001,6 @@ mod tests {
|
||||
fn schema_objects_per_dialect() {
|
||||
use crate::DbObjectKind as K;
|
||||
assert!(routines(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(packages(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(sequences(DbDriver::Mysql, "db").is_none());
|
||||
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,7 +18,6 @@ semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
dunce.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) {
|
||||
@@ -441,13 +413,11 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
/// `p` with its parent canonicalized but the file name as found: PATH
|
||||
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
|
||||
/// their real directory, while a linked program (`cargo` → rustup) keeps
|
||||
/// its link name so the shim still dispatches on argv[0]. `dunce` keeps the
|
||||
/// path in its usual form on Windows: a `\\?\`-prefixed pin would never
|
||||
/// match the path the program was actually started with.
|
||||
/// its link name so the shim still dispatches on argv[0].
|
||||
fn canonical_dir(p: &Path) -> PathBuf {
|
||||
let Some(dir) = p.parent() else { return p.to_path_buf() };
|
||||
let Some(name) = p.file_name() else { return p.to_path_buf() };
|
||||
dunce::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
@@ -500,20 +470,6 @@ mod tests {
|
||||
list.iter().map(|s| s.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn pinned_program_path_has_no_verbatim_prefix() {
|
||||
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
|
||||
// stored in that form would never match the plain path the program is
|
||||
// started with. dunce keeps the usual form.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
|
||||
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
|
||||
let shown = pinned.to_string_lossy();
|
||||
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
|
||||
assert!(pinned.is_absolute());
|
||||
}
|
||||
|
||||
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
|
||||
Request {
|
||||
subject: Subject::Installed("p".into()),
|
||||
@@ -554,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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod output;
|
||||
mod target;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
@@ -8,7 +9,42 @@ 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> {
|
||||
debug_start_request(host, window, config, None).await
|
||||
}
|
||||
|
||||
pub async fn debug_start_request(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: LaunchConfig,
|
||||
request: Option<ide_core::RequestId>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let cancel = request.map(|id| host.begin_request(id)).unwrap_or_default();
|
||||
let result = debug_start_inner(host, window, config, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn debug_start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: LaunchConfig,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
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, cancel).await;
|
||||
}
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -28,7 +64,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,
|
||||
@@ -383,7 +418,9 @@ pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSe
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("debug_start", |c| async move {
|
||||
debug_start_request(&c.host, &c.window, c.arg("config")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("debug_set_breakpoints", |c| async move {
|
||||
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> 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 program = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("__runTargetProgram").and_then(|p| p.as_str()).map(str::to_owned))
|
||||
.or_else(|| config.program.clone())
|
||||
.unwrap_or_else(|| dbg.command.clone());
|
||||
let metadata = serde_json::from_value(json!({"id":"debug", "name":config.name, "kind":"shell", "command":program, "args":[], "cwd":config.cwd, "env":{}, "detected":false, "isTest":false}))
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let (validator, root, target, token) = (plugins.clone(), project.root.clone(), id.clone(), cancel.clone());
|
||||
host.blocking(move || {
|
||||
validator.prepare_run_target_launch(&root, &target, &metadata, ide_plugin::RunTargetOperation::Debug, &token)
|
||||
})
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
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");
|
||||
extra.as_object_mut().unwrap().remove("__runTargetProgram");
|
||||
// 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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -546,6 +546,42 @@ 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;
|
||||
}
|
||||
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(®istration.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 });
|
||||
}
|
||||
}
|
||||
return Err(IdeError::unavailable("plugin run target is no longer available"));
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
@@ -886,13 +922,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 +948,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());
|
||||
}
|
||||
@@ -2193,55 +2228,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;
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
@@ -112,68 +112,6 @@ pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeRes
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file a Python import path names: `a.b.c` is `a/b/c.py(i)` or `a/b/c/__init__.py(i)`
|
||||
/// in the project or one of its content roots (an attached dependency project included);
|
||||
/// `.x` names it relative to `from`'s directory. Neither the language server (it analyses a
|
||||
/// single root) nor the definition index resolves a package segment, so ⌘B on the `a.b` of
|
||||
/// `from a.b import x` needs this.
|
||||
pub async fn nav_resolve_module(host: &Host, window: &str, name: String, from: IpcPath) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let dots = name.len() - name.trim_start_matches('.').len();
|
||||
let parts: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if parts.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rel = parts.iter().collect::<PathBuf>();
|
||||
let bases: Vec<PathBuf> = if dots > 0 {
|
||||
let dir = PathBuf::from(from);
|
||||
let dir = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
|
||||
let base = (0..dots.saturating_sub(1)).fold(dir, |p, _| p.parent().map(Path::to_path_buf).unwrap_or(p));
|
||||
vec![base]
|
||||
} else {
|
||||
crate::project::navigation_roots(&project, &from)
|
||||
};
|
||||
for base in bases {
|
||||
let dir = base.join(&rel);
|
||||
for candidate in
|
||||
[dir.with_extension("py"), dir.with_extension("pyi"), dir.join("__init__.py"), dir.join("__init__.pyi")]
|
||||
{
|
||||
if candidate.is_file() {
|
||||
return Ok(Some(IpcPath::from(candidate)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Index definitions named `name` in the other roots `from` may navigate into (its configured
|
||||
/// dependencies and extra content roots — see `navigation_roots`): the cross-root answer when
|
||||
/// the language server cannot resolve an import into an attached dependency project. `from`'s
|
||||
/// own root is left out: the server sees it, so a name it could not resolve there is not
|
||||
/// answered by every declaration that happens to share it (any `save` for `repo.save()`).
|
||||
pub async fn nav_index_definitions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
name: String,
|
||||
from: IpcPath,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let roots = crate::project::navigation_roots(&project, &from);
|
||||
let Some((_own, roots)) = roots.split_first() else { return Ok(Vec::new()) };
|
||||
if roots.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Filter inside the index: a cap applied before the root filter can fill with
|
||||
// own-root matches and evict the dependency roots entirely.
|
||||
Ok(project.index.symbols_named_within(&name, None, roots, 500))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
@@ -243,12 +181,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("nav_resolve_module", |c| async move {
|
||||
nav_resolve_module(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("nav_index_definitions", |c| async move {
|
||||
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
|
||||
@@ -221,11 +221,8 @@ pub fn set_settings(
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.")
|
||||
|| key == "files"
|
||||
|| key == "files.composeFileOverrides"
|
||||
|| key.starts_with("files.composeFileOverrides.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
|| key.starts_with("inspections.");
|
||||
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.
|
||||
|
||||
@@ -280,11 +280,14 @@ async fn start_inner(
|
||||
let token = cancel.clone();
|
||||
let config = host
|
||||
.blocking(move || {
|
||||
let operation = if config.is_test {
|
||||
ide_plugin::RunTargetOperation::Test
|
||||
} else {
|
||||
ide_plugin::RunTargetOperation::Run
|
||||
};
|
||||
let operation =
|
||||
if config.extras.params.get("plugin.targetOperation").is_some_and(|op| op == "debug") {
|
||||
ide_plugin::RunTargetOperation::Debug
|
||||
} else if config.is_test {
|
||||
ide_plugin::RunTargetOperation::Test
|
||||
} else {
|
||||
ide_plugin::RunTargetOperation::Run
|
||||
};
|
||||
plugins.prepare_run_target_launch(&root, &id, &config, operation, &token)
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
@@ -992,9 +992,7 @@ mod tests {
|
||||
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)
|
||||
std::fs::File::open(path)
|
||||
.unwrap()
|
||||
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
|
||||
.unwrap();
|
||||
|
||||
@@ -380,7 +380,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 +452,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 +463,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 +806,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 +1079,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")));
|
||||
}
|
||||
@@ -2564,7 +2537,6 @@ impl PluginHost {
|
||||
plugin_id: plugin.id().to_owned(),
|
||||
version: plugin.manifest().version.clone(),
|
||||
granted,
|
||||
subject: plugin.subject(),
|
||||
host: api,
|
||||
limits: self.limits,
|
||||
};
|
||||
@@ -2942,13 +2914,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 +2930,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() }));
|
||||
|
||||
@@ -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,6 +96,15 @@ 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()
|
||||
}
|
||||
pub fn prepare_run_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
self.prepare_target(root, id, config, None)
|
||||
}
|
||||
|
||||
/// Prepares a user launch, including the optional plugin validation gate.
|
||||
pub fn prepare_run_target_launch(
|
||||
&self,
|
||||
@@ -95,6 +113,16 @@ impl PluginHost {
|
||||
config: &ide_run::RunConfig,
|
||||
operation: ide_plugin::RunTargetOperation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
self.prepare_target(root, id, config, Some((operation, cancel)))
|
||||
}
|
||||
|
||||
fn prepare_target(
|
||||
&self,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
launch: Option<(ide_plugin::RunTargetOperation, &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"));
|
||||
@@ -108,7 +136,10 @@ impl PluginHost {
|
||||
.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 {
|
||||
let validates = self.get(&target.plugin_id)?.meta.lock().has_run_target_validation;
|
||||
if let Some((operation, cancel)) = launch
|
||||
&& validates
|
||||
{
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,11 +13,9 @@ use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
@@ -141,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();
|
||||
@@ -283,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
|
||||
}));
|
||||
}
|
||||
{
|
||||
@@ -1199,81 +1251,9 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
extra_content_roots(settings, root, build_cache)
|
||||
}
|
||||
|
||||
/// The roots a file may navigate into: its own root (the workspace member containing it, or
|
||||
/// the project root) plus that root's configured dependencies. A workspace member depends
|
||||
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
|
||||
/// being attached to the workspace never makes its code navigable by itself.
|
||||
/// Cached navigation roots per (project, file), with the instant they were computed.
|
||||
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
|
||||
|
||||
pub(crate) fn navigation_roots(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
// Hover probes and import-segment clicks repeat these lookups for the same file,
|
||||
// and each uncached call re-reads the workspace file: memoize briefly. The short
|
||||
// TTL keeps settings and workspace edits near-immediate.
|
||||
static CACHE: Mutex<Option<NavigationRootsCache>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const CAP: usize = 256;
|
||||
let key = (project.root.clone(), from.to_path_buf());
|
||||
if let Some((at, roots)) = CACHE.lock().as_ref().and_then(|m| m.get(&key))
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return roots.clone();
|
||||
}
|
||||
let roots = navigation_roots_uncached(project, from);
|
||||
if let Some(map) = CACHE.lock().as_mut() {
|
||||
if map.len() >= CAP {
|
||||
map.retain(|_, (at, _)| at.elapsed() < TTL);
|
||||
}
|
||||
map.insert(key, (Instant::now(), roots.clone()));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn navigation_roots_uncached(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
let settings = project.settings.effective();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
|
||||
let own = workspace
|
||||
.as_ref()
|
||||
.and_then(|w| w.projects.iter().filter(|p| from.starts_with(p)).max_by_key(|p| p.components().count()).cloned())
|
||||
.unwrap_or_else(|| project.root.clone());
|
||||
let mut roots = vec![own.clone()];
|
||||
let own_key = ide_core::ipc_path::to_ipc(&own);
|
||||
if let Some(deps) = member_dependencies_for(&settings.project_structure.member_dependencies, &own_key) {
|
||||
for dep in deps {
|
||||
let dep = PathBuf::from(dep.trim());
|
||||
if dep.is_absolute() && dep.is_dir() && !roots.contains(&dep) {
|
||||
roots.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
if workspace.is_none() {
|
||||
for root in content_roots(&settings, &project.root, &project.build_cache_dir()) {
|
||||
if !roots.contains(&root) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// The dependency roots configured for `own`, keyed by IPC path. The settings UI writes
|
||||
/// keys as it received the member paths (IPC form); a hand-edited settings.toml may not,
|
||||
/// so compare normalized rather than only by exact match.
|
||||
fn member_dependencies_for<'a>(map: &'a BTreeMap<String, Vec<String>>, own_key: &str) -> Option<&'a Vec<String>> {
|
||||
if let Some(deps) = map.get(own_key) {
|
||||
return Some(deps);
|
||||
}
|
||||
map.iter().find(|(key, _)| ide_core::ipc_path::to_ipc(&PathBuf::from(key)) == own_key).map(|(_, deps)| deps)
|
||||
}
|
||||
|
||||
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let user = settings
|
||||
.project_structure
|
||||
.content_roots
|
||||
.iter()
|
||||
.chain(settings.project_structure.dependencies.iter())
|
||||
.map(|r| PathBuf::from(r.trim()));
|
||||
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
@@ -1885,34 +1865,3 @@ mod composer_tests {
|
||||
assert!(!changed(&[]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_roots_tests {
|
||||
use super::content_roots;
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn member_dependencies_match_hand_written_keys() {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
map.insert("B:\\proj\\member".to_string(), vec!["B:/dep".to_string()]);
|
||||
let deps = super::member_dependencies_for(&map, "B:/proj/member");
|
||||
assert_eq!(deps.map(Vec::as_slice), Some(["B:/dep".to_string()].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_projects_join_the_content_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
let shared = tmp.path().join("shared");
|
||||
let dep = tmp.path().join("lib");
|
||||
for dir in [&root, &shared, &dep] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.project_structure.content_roots = vec![shared.to_string_lossy().into_owned()];
|
||||
settings.project_structure.dependencies =
|
||||
vec![dep.to_string_lossy().into_owned(), root.to_string_lossy().into_owned()];
|
||||
let roots = content_roots(&settings, &root, &tmp.path().join("build"));
|
||||
assert_eq!(roots, vec![shared, dep]);
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
@@ -370,7 +370,7 @@ async fn run_target_api15_overrides_and_frozen_api14_registration() {
|
||||
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");
|
||||
let tool = f._tmp.path().join("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 =
|
||||
@@ -410,16 +410,7 @@ async fn run_target_api15_overrides_and_frozen_api14_registration() {
|
||||
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,
|
||||
plugins.prepare_run_target(&f.root, &target.id, &php).unwrap().args,
|
||||
["wrapped", "--cwd=/container", "php", "x.php"]
|
||||
);
|
||||
}
|
||||
@@ -465,7 +456,7 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
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");
|
||||
let tool = f._tmp.path().join("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();
|
||||
@@ -481,7 +472,7 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
}
|
||||
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] {
|
||||
for operation in [RunTargetOperation::Run, RunTargetOperation::Test, RunTargetOperation::Debug] {
|
||||
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()));
|
||||
@@ -493,6 +484,8 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap();
|
||||
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
|
||||
// Non-launch consumers (LSP and document reads) do not invoke validation.
|
||||
assert!(plugins.prepare_run_target(&f.root, &id("refuse"), &config).is_ok());
|
||||
let selected = id("refuse");
|
||||
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
|
||||
for is_test in [false, true] {
|
||||
@@ -506,6 +499,21 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
|
||||
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
|
||||
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
|
||||
}
|
||||
for adapter in ["php-debug", "debugpy"] {
|
||||
let launch = ide_dap::LaunchConfig {
|
||||
name: "Refused debug".into(),
|
||||
adapter: adapter.into(),
|
||||
request: "launch".into(),
|
||||
program: Some("app.php".into()),
|
||||
args: Vec::new(),
|
||||
cwd: Some(f.root.clone().into()),
|
||||
env: Default::default(),
|
||||
extra_json: Some(serde_json::json!({"__runTarget":selected, "__runTargetProgram":"php"}).to_string()),
|
||||
};
|
||||
let error = ide_host::ops::debug::debug_start(&f.host, "main", launch).await.unwrap_err();
|
||||
assert!(error.message.contains("Project runtime is stopped"), "{adapter}: {error}");
|
||||
assert!(f.host.debugger.sessions(None).is_empty());
|
||||
}
|
||||
let error = plugins
|
||||
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
|
||||
.unwrap_err();
|
||||
|
||||
@@ -98,44 +98,6 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
/// Find in Files over the fixture's files on disk.
|
||||
fn search_text(
|
||||
&self,
|
||||
_client: Option<&str>,
|
||||
root: &Path,
|
||||
query: ide_plugin::TextQuery,
|
||||
limit: u32,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<ide_plugin::TextSearch, HostError> {
|
||||
let query = ide_search::SearchQuery {
|
||||
pattern: query.pattern,
|
||||
is_regex: query.regex,
|
||||
case_sensitive: query.case_sensitive,
|
||||
whole_word: query.whole_word,
|
||||
include: query.include,
|
||||
exclude: query.exclude,
|
||||
scope: ide_search::SearchScope::Project,
|
||||
max_matches: limit,
|
||||
context: Default::default(),
|
||||
};
|
||||
let files = Mutex::new(Vec::new());
|
||||
let summary = ide_search::search(root, &query, &|_| None, cancel, &|file| files.lock().push(file))
|
||||
.map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let matches = files
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.flat_map(|file| {
|
||||
file.matches.into_iter().map(move |m| ide_plugin::TextMatch {
|
||||
location: ide_plugin::Location {
|
||||
path: PathBuf::from(&file.path.0),
|
||||
range: ide_core::Range::new(ide_core::Pos::new(m.line, 0), ide_core::Pos::new(m.line, 0)),
|
||||
},
|
||||
line: m.text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(ide_plugin::TextSearch { matches, limit_hit: summary.limit_hit })
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if path.ends_with("event-barrier.txt") {
|
||||
let gate = self.event_read_gate.lock().take();
|
||||
@@ -489,29 +451,6 @@ fn read_file_is_confined_to_the_project() {
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `project.read-bytes` returns the exact bytes on disk, where `read-file`
|
||||
/// wants UTF-8, and follows `read-file`'s confinement rules.
|
||||
#[test]
|
||||
fn read_bytes_reads_the_project_file_exactly_and_stays_inside() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_pkg());
|
||||
let binary = [0x25u8, 0x50, 0x44, 0x46, 0x00, 0xff, 0x1b, 0x7f];
|
||||
std::fs::write(f.project.join("logo.bin"), binary).unwrap();
|
||||
let hex = binary.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some("logo.bin")).unwrap().unwrap(), format!("8 {hex}"));
|
||||
let abs = f.project.join("logo.bin");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some(abs.to_str().unwrap())).unwrap().unwrap(), format!("8 {hex}"));
|
||||
// The same refusal and not-found behaviour as `read-file`; like every
|
||||
// refusal, it is the plugin's to handle: it keeps running.
|
||||
let err = f.run("misbehave", "read-bytes", Some("../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src/missing.bin")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src")).unwrap_err();
|
||||
assert!(err.contains("not a file"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `fs.read` reaches the granted paths and nothing else, through the real
|
||||
/// component: the same checks the runtime makes, against a real file system.
|
||||
#[test]
|
||||
@@ -603,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]
|
||||
@@ -2801,12 +2716,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
|
||||
std::fs::write(&java_path, java).unwrap();
|
||||
std::fs::write(&xml_path, xml).unwrap();
|
||||
// Noise: other mappers, some large, that the lookup must not read one by one.
|
||||
let filler = " <select id=\"q\">select 1</select>\n".repeat(3000);
|
||||
for i in 0..600 {
|
||||
let body = if i % 50 == 0 { filler.as_str() } else { "" };
|
||||
let other = format!("<mapper namespace=\"com.acme.Other{i}\">\n{body}</mapper>\n");
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), other).unwrap();
|
||||
// Noise the index has to skip.
|
||||
for i in 0..200 {
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
|
||||
}
|
||||
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
|
||||
path: path.to_path_buf(),
|
||||
@@ -2815,9 +2727,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
text: text.into(),
|
||||
};
|
||||
let jdoc = doc(&java_path, "java", java);
|
||||
// The first call searches the XML files; on a slow machine that can run past the
|
||||
// edit-path budget, so the answer is dropped and calls queued behind it are
|
||||
// skipped. The editor asks again; so does this.
|
||||
// The first call indexes the XML files; on a slow machine that runs past the
|
||||
// edit-path budget, so the answer is dropped (the index is kept) and calls
|
||||
// queued behind it are skipped. The editor asks again; so does this.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let markers = loop {
|
||||
let markers = f.host.provide_line_markers(&jdoc);
|
||||
@@ -2846,7 +2758,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
"not its language"
|
||||
);
|
||||
|
||||
// Warm (lookup cached): well inside the edit-path budget.
|
||||
// Warm (index cached): well inside the edit-path budget.
|
||||
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..20 {
|
||||
@@ -5787,7 +5699,6 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
("open-editors", "", "editor.read"),
|
||||
("open-editor", "a.txt", "project.read"),
|
||||
("search-text", "needle", "project.read"),
|
||||
("read-bytes", "a.txt", "project.read"),
|
||||
("problems", "", "project.read"),
|
||||
("decorations", "a.txt", "project.read"),
|
||||
("clear-decorations", "", "project.read"),
|
||||
@@ -5818,158 +5729,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());
|
||||
|
||||
@@ -616,16 +616,16 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
|
||||
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;
|
||||
until("remote composer override", 5, || project.join("composer.txt").exists()).await;
|
||||
assert_eq!(std::fs::read_to_string(project.join("composer.txt")).unwrap(), "<composer>\n<install>\n");
|
||||
invoke(
|
||||
&peer,
|
||||
"plugin_run_command",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -52,8 +52,6 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn parse_env(text: &str) -> IdeResult<EnvMap> {
|
||||
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
|
||||
let mut out = EnvMap::new();
|
||||
@@ -264,12 +262,4 @@ mod tests {
|
||||
let envs = Environments::load(Some(&other.path().join("s.http")), Some(root)).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_env_files_saved_with_a_byte_order_mark() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(PUBLIC_FILE), "\u{feff}{\"dev\":{\"host\":\"h\"}}").unwrap();
|
||||
let envs = Environments::load(Some(&tmp.path().join("a.http")), Some(tmp.path())).unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
}
|
||||
}
|
||||
@@ -319,19 +319,6 @@ impl ProjectIndex {
|
||||
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given) whose file is inside one of `roots`
|
||||
/// (the project root plus its configured dependency roots): answers bounded by the cap
|
||||
/// never evict matches from a root because another root filled it first.
|
||||
pub fn symbols_named_within(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
matching::symbols_named_within(&self.shared.data.read(), name, kind, roots, limit)
|
||||
}
|
||||
|
||||
/// Incrementally re-indexes changed paths (called from the vfs watcher).
|
||||
/// Cheap: the work is queued for the indexing thread.
|
||||
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
|
||||
@@ -526,26 +526,11 @@ pub(crate) fn find_symbols_until(
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
|
||||
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
|
||||
symbols_named_within(data, name, kind, &[], limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given) whose file is inside one of
|
||||
/// `roots` (empty = every indexed file), by path then position. Filtering while walking
|
||||
/// entries keeps symbols outside the roots from materialising or filling the cap.
|
||||
pub(crate) fn symbols_named_within(
|
||||
data: &IndexData,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
let mask = char_mask(name);
|
||||
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
|
||||
let mut out: Vec<SymbolMatch> = data
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|e| within(e))
|
||||
.flat_map(|e| {
|
||||
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
|
||||
move |s| SymbolMatch {
|
||||
|
||||
@@ -528,27 +528,6 @@ fn css_selectors_are_found_by_exact_name() {
|
||||
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_named_within_keeps_out_of_root_matches_from_evicting_a_root() {
|
||||
let f = Fixture::new(&[
|
||||
("a/w.rs", "struct Decoder;\nstruct Decoder;\n"),
|
||||
("b/x.rs", "struct Decoder;\nstruct Decoder;\nstruct Decoder;\n"),
|
||||
("dep/b.rs", "struct Decoder;\n"),
|
||||
]);
|
||||
let idx = f.open();
|
||||
let own = f.root.join("a");
|
||||
let dep = f.root.join("dep");
|
||||
// Unfiltered, the cap fills in rel-path order — own root, then the out-of-root
|
||||
// content root b/ — so dep/ is evicted before the caller's root filter runs.
|
||||
let unfiltered = idx.symbols_named("Decoder", None, 3);
|
||||
let unfiltered: Vec<&str> = unfiltered.iter().map(|m| m.rel_path.as_ref()).collect();
|
||||
assert_eq!(unfiltered, vec!["a/w.rs", "a/w.rs", "b/x.rs"]);
|
||||
let found: Vec<String> =
|
||||
idx.symbols_named_within("Decoder", None, &[own, dep], 3).into_iter().map(|m| m.rel_path).collect();
|
||||
assert_eq!(found, vec!["a/w.rs".to_string(), "a/w.rs".to_string(), "dep/b.rs".to_string()]);
|
||||
assert!(idx.symbols_named_within("Decoder", None, &[f.root.join("none")], 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_roots_and_excludes_change_without_reopening() {
|
||||
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,10 +10,6 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pinned Compose Specification JSON Schema URL associated with `yaml-language-server`.
|
||||
pub const COMPOSE_SCHEMA_URL: &str =
|
||||
"https://raw.githubusercontent.com/compose-spec/compose-go/v2.16.2/schema/compose-spec.json";
|
||||
|
||||
mod markers;
|
||||
pub mod plugin_point;
|
||||
mod registry;
|
||||
@@ -180,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,
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
//! Docker Compose content detection and `yaml-language-server` schema association
|
||||
//! for open YAML documents whose filename does not already match `*compose*.y(a)ml`.
|
||||
|
||||
use ide_language::{COMPOSE_SCHEMA_URL, LanguageServerConfig};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SERVER: &str = "yaml-language-server";
|
||||
|
||||
const TOP_SECTIONS: &[&str] = &["version", "name", "networks", "volumes", "configs", "secrets", "include"];
|
||||
|
||||
const SERVICE_KEYS: &[&str] = &[
|
||||
"image",
|
||||
"build",
|
||||
"container_name",
|
||||
"hostname",
|
||||
"domainname",
|
||||
"ports",
|
||||
"expose",
|
||||
"environment",
|
||||
"env_file",
|
||||
"volumes",
|
||||
"volumes_from",
|
||||
"networks",
|
||||
"network_mode",
|
||||
"depends_on",
|
||||
"command",
|
||||
"entrypoint",
|
||||
"restart",
|
||||
"pull_policy",
|
||||
"healthcheck",
|
||||
"deploy",
|
||||
"develop",
|
||||
"labels",
|
||||
"annotations",
|
||||
"platform",
|
||||
"profiles",
|
||||
"user",
|
||||
"working_dir",
|
||||
"privileged",
|
||||
"read_only",
|
||||
"stdin_open",
|
||||
"tty",
|
||||
"init",
|
||||
"cap_add",
|
||||
"cap_drop",
|
||||
"cgroup",
|
||||
"cgroup_parent",
|
||||
"configs",
|
||||
"secrets",
|
||||
"cpus",
|
||||
"cpu_count",
|
||||
"cpu_percent",
|
||||
"cpu_shares",
|
||||
"cpu_period",
|
||||
"cpu_quota",
|
||||
"cpuset",
|
||||
"devices",
|
||||
"dns",
|
||||
"dns_opt",
|
||||
"dns_search",
|
||||
"extends",
|
||||
"external_links",
|
||||
"extra_hosts",
|
||||
"group_add",
|
||||
"ipc",
|
||||
"isolation",
|
||||
"links",
|
||||
"logging",
|
||||
"mac_address",
|
||||
"mem_limit",
|
||||
"mem_reservation",
|
||||
"oom_kill_disable",
|
||||
"oom_score_adj",
|
||||
"pid",
|
||||
"pids_limit",
|
||||
"post_start",
|
||||
"pre_stop",
|
||||
"runtime",
|
||||
"scale",
|
||||
"security_opt",
|
||||
"shm_size",
|
||||
"stop_grace_period",
|
||||
"stop_signal",
|
||||
"storage_opt",
|
||||
"sysctls",
|
||||
"tmpfs",
|
||||
"ulimits",
|
||||
"userns_mode",
|
||||
"uts",
|
||||
];
|
||||
|
||||
fn parse_key(trimmed: &str) -> Option<&str> {
|
||||
let colon = trimmed.find(':')?;
|
||||
let after = &trimmed[colon + 1..];
|
||||
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let key = trimmed[..colon].trim_end();
|
||||
let key = key
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
|
||||
.unwrap_or(key);
|
||||
(!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')))
|
||||
.then_some(key)
|
||||
}
|
||||
|
||||
/// Whether `path`'s filename already matches the Compose schema globs in `languages/yaml.toml`.
|
||||
pub fn is_named_compose_file(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
|
||||
let Some(stem) = name.strip_suffix(".yaml").or_else(|| name.strip_suffix(".yml")) else {
|
||||
return false;
|
||||
};
|
||||
// `<head>` or `<head>.*`, where `<head>` is `compose`, `docker-compose`, `*-compose` or `*.compose`.
|
||||
let is_head = |head: &str| {
|
||||
head == "compose"
|
||||
|| head == "docker-compose"
|
||||
|| head.strip_suffix("-compose").is_some_and(|p| !p.is_empty())
|
||||
|| head.strip_suffix(".compose").is_some_and(|p| !p.is_empty())
|
||||
};
|
||||
is_head(stem) || stem.match_indices('.').any(|(i, _)| i + 1 < stem.len() && is_head(&stem[..i]))
|
||||
}
|
||||
|
||||
/// Whether `path` is a YAML file whose name does not match a Compose glob, but whose content is Compose.
|
||||
pub fn is_content_detected_compose_file(path: &Path, text: &str) -> bool {
|
||||
!is_named_compose_file(path) && is_compose_content(text)
|
||||
}
|
||||
|
||||
/// Whether `text` looks like a Docker Compose YAML configuration.
|
||||
pub fn is_compose_content(text: &str) -> bool {
|
||||
let mut has_services = false;
|
||||
let mut has_compose_top = false;
|
||||
let mut has_api_version = false;
|
||||
let mut has_kind = false;
|
||||
let mut has_on = false;
|
||||
let mut has_jobs = false;
|
||||
let mut in_services = false;
|
||||
let mut service_indent = None;
|
||||
let mut prop_indent = None;
|
||||
let mut has_service_prop = false;
|
||||
|
||||
for raw in text.lines().take(600) {
|
||||
let line = raw.trim_end_matches('\r');
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let lead = line.len() - trimmed.len();
|
||||
if lead == 0 {
|
||||
in_services = false;
|
||||
if let Some(key) = parse_key(trimmed) {
|
||||
if matches!(key, "openapi" | "swagger" | "AWSTemplateFormatVersion") {
|
||||
return false;
|
||||
}
|
||||
has_api_version |= key == "apiVersion";
|
||||
has_kind |= key == "kind";
|
||||
has_on |= key == "on";
|
||||
has_jobs |= key == "jobs";
|
||||
if (has_api_version && has_kind) || (has_on && has_jobs) {
|
||||
return false;
|
||||
}
|
||||
if key == "services" {
|
||||
has_services = true;
|
||||
in_services = true;
|
||||
service_indent = None;
|
||||
prop_indent = None;
|
||||
} else if TOP_SECTIONS.contains(&key) || key.starts_with("x-") {
|
||||
has_compose_top = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_services && !has_service_prop {
|
||||
let svc_indent = *service_indent.get_or_insert(lead);
|
||||
if lead == svc_indent {
|
||||
prop_indent = None;
|
||||
} else if lead > svc_indent {
|
||||
let p_indent = *prop_indent.get_or_insert(lead);
|
||||
if lead == p_indent
|
||||
&& let Some(prop) = parse_key(trimmed)
|
||||
&& (SERVICE_KEYS.contains(&prop) || prop.starts_with("x-"))
|
||||
{
|
||||
has_service_prop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
has_services && (has_compose_top || has_service_prop)
|
||||
}
|
||||
|
||||
/// Appends open content-detected Compose files to `yaml-language-server`'s
|
||||
/// `yaml.schemas[COMPOSE_SCHEMA_URL]` unless excluded by user settings.
|
||||
pub fn configure_yaml_compose_schemas(cfg: &mut LanguageServerConfig, compose_files: &[PathBuf]) {
|
||||
if cfg.name != SERVER || compose_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
let schemas = settings
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.entry("yaml")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.and_then(|yaml| yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut());
|
||||
if let Some(schemas) = schemas {
|
||||
let entry = schemas.entry(COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
|
||||
if let Value::String(single) = entry {
|
||||
*entry = json!([single.clone()]);
|
||||
}
|
||||
if let Some(list) = entry.as_array_mut() {
|
||||
for path in compose_files {
|
||||
let p = path.to_string_lossy().replace('\\', "/");
|
||||
let negated = format!("!{p}");
|
||||
if list.iter().any(|v| v.as_str() == Some(&p) || v.as_str() == Some(&negated)) {
|
||||
continue;
|
||||
}
|
||||
list.push(Value::String(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
#[test]
|
||||
fn detects_compose_content_and_ignores_other_manifests() {
|
||||
assert!(is_compose_content("services:\n db:\n image: postgres:16\n"));
|
||||
assert!(is_compose_content("version: \"3.9\"\nservices:\n app:\n foo: bar\n"));
|
||||
assert!(!is_compose_content("apiVersion: v1\nkind: Service\nservices:\n db:\n image: x\n"));
|
||||
assert!(!is_compose_content("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"));
|
||||
assert!(is_named_compose_file(Path::new("/work/docker-compose.yml")));
|
||||
assert!(is_named_compose_file(Path::new("/work/playground-compose.yaml")));
|
||||
assert!(!is_named_compose_file(Path::new("/work/custom-stack.yml")));
|
||||
for named in
|
||||
["compose.yaml", "compose.override.yml", "docker-compose.prod.yaml", "a.compose.yml", "x-compose.ci.yml"]
|
||||
{
|
||||
assert!(is_named_compose_file(Path::new(named)), "{named}");
|
||||
}
|
||||
for unnamed in ["docker-compose-dev.yml", "compose-prod.yaml", "composer.yml", "decompose.yaml", "-compose.yml"]
|
||||
{
|
||||
assert!(!is_named_compose_file(Path::new(unnamed)), "{unnamed}");
|
||||
}
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose-dev.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(!is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/custom-stack.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_content_detected_files_into_yaml_schemas_unless_excluded() {
|
||||
let reg = LanguageRegistry::load(None).unwrap();
|
||||
let yaml = reg.get(&ide_core::LanguageId::new("yaml")).unwrap();
|
||||
let mut cfg = yaml.language_servers[0].clone();
|
||||
let a = PathBuf::from("/work/custom-stack.yml");
|
||||
let b = PathBuf::from("/work/excluded.yml");
|
||||
cfg.settings.as_mut().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!("!/work/excluded.yml"));
|
||||
|
||||
configure_yaml_compose_schemas(&mut cfg, &[a, b]);
|
||||
let list = cfg.settings.as_ref().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(list.contains(&"compose.yml"));
|
||||
assert!(list.contains(&"*-compose.yml"));
|
||||
assert!(list.contains(&"/work/custom-stack.yml"));
|
||||
assert!(!list.contains(&"/work/excluded.yml"));
|
||||
assert!(list.contains(&"!/work/excluded.yml"));
|
||||
}
|
||||
}
|
||||
@@ -998,6 +998,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
|
||||
@@ -1559,6 +1560,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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 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`],
|
||||
@@ -10,7 +10,6 @@ mod angular;
|
||||
mod arduino;
|
||||
mod caps;
|
||||
mod clang_tidy;
|
||||
mod compose;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
@@ -26,6 +25,7 @@ mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
mod target;
|
||||
mod tcp;
|
||||
mod text;
|
||||
mod ts_file_moves;
|
||||
@@ -87,6 +87,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
|
||||
@@ -108,8 +112,6 @@ struct DocState {
|
||||
version: i32,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Attached>,
|
||||
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
|
||||
compose_detected: bool,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -185,7 +187,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 {
|
||||
@@ -201,6 +203,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>>>,
|
||||
@@ -409,6 +413,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),
|
||||
@@ -506,13 +512,15 @@ impl LspManager {
|
||||
// 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();
|
||||
}
|
||||
@@ -563,8 +571,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();
|
||||
@@ -666,7 +679,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
|
||||
{
|
||||
@@ -677,7 +691,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() {
|
||||
@@ -1020,7 +1034,6 @@ impl LspManager {
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
|
||||
let mut state = DocState {
|
||||
path,
|
||||
canonical,
|
||||
@@ -1029,12 +1042,9 @@ impl LspManager {
|
||||
version: version as i32,
|
||||
text: Arc::new(DocText::new(text)),
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
};
|
||||
let compose_added = {
|
||||
{
|
||||
let mut docs = self.docs.lock();
|
||||
let already_detected =
|
||||
compose_detected && docs.values().any(|d| d.compose_detected && d.path == state.path);
|
||||
let params = state.did_open_params();
|
||||
for a in &mut state.servers {
|
||||
if let Some(run) = a.server.running() {
|
||||
@@ -1045,17 +1055,13 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
docs.insert(doc, state);
|
||||
compose_detected && !already_detected
|
||||
};
|
||||
}
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
for s in &servers {
|
||||
self.ensure_started(s);
|
||||
if s.running().is_some()
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
if s.name == compose::SERVER && compose_added {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
let server = s.clone();
|
||||
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
|
||||
}
|
||||
@@ -1126,18 +1132,7 @@ impl LspManager {
|
||||
tracing::debug!("{}: didChange: {e}", server.name);
|
||||
}
|
||||
}
|
||||
let compose_changed = if d.language.as_str() == "yaml" && !compose::is_named_compose_file(&d.path) {
|
||||
let now = compose::is_compose_content(text.as_str());
|
||||
let changed = std::mem::replace(&mut d.compose_detected, now) != now;
|
||||
let closed_path = d.path.clone();
|
||||
changed && !docs.iter().any(|(id, other)| *id != doc && other.compose_detected && other.path == closed_path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
drop(docs);
|
||||
if compose_changed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
self.schedule_pull(doc, None, pull::AFTER_EDIT);
|
||||
self.invalidate_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
@@ -1197,16 +1192,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn close_document(&self, doc: DocId) {
|
||||
let (d, compose_removed) = {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.remove(&doc) else { return };
|
||||
let removed =
|
||||
d.compose_detected && !docs.values().any(|other| other.compose_detected && other.path == d.path);
|
||||
(d, removed)
|
||||
};
|
||||
if compose_removed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
let Some(d) = self.docs.lock().remove(&doc) else { return };
|
||||
for a in &d.servers {
|
||||
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
|
||||
let Some(run) = a.server.running() else { continue };
|
||||
@@ -2955,8 +2941,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()
|
||||
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())
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The binary archive behind a library document, never its displayed tab title.
|
||||
@@ -3023,6 +3016,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());
|
||||
}
|
||||
@@ -3177,6 +3193,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));
|
||||
}
|
||||
@@ -3197,23 +3218,26 @@ 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 cfg.name == compose::SERVER {
|
||||
compose::configure_yaml_compose_schemas(&mut cfg, &self.open_compose_files());
|
||||
if let Some(target) = &mut cfg.target {
|
||||
target.origin = Some(server.id);
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn open_compose_files(&self) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
self.docs.lock().values().filter(|d| d.compose_detected).map(|d| d.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
/// Restarts running servers whose settings-derived configuration changed
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
@@ -3284,6 +3308,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
|
||||
@@ -3304,7 +3333,7 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
self.detach_shadowed_servers().await;
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
@@ -3341,9 +3370,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();
|
||||
@@ -3358,7 +3387,10 @@ 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)))
|
||||
|| (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();
|
||||
@@ -3414,7 +3446,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;
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())], &[]);
|
||||
@@ -5943,3 +5991,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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +102,54 @@ async fn a_request_that_needs_a_crashed_jdtls_reports_why_it_crashed() {
|
||||
assert!(error.message.contains("'python' is not recognized"), "{}", error.message);
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn target_launch_failure_shows_exact_argv_and_stderr_tail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let wrapper = root.join("target wrapper");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\ni=0\nwhile [ $i -lt 60 ]; do echo stderr-$i >&2; i=$((i+1)); done\nexit 37\n")
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
|
||||
let mut server: LanguageServerConfig = toml::from_str("name='target-ls'\ncommand='unused'").unwrap();
|
||||
server.command = wrapper.to_string_lossy().into_owned();
|
||||
server.args = vec!["literal prefix".into(), "--cwd=/container".into(), "missing-lsp".into()];
|
||||
server.pinned = Some(ide_language::ProgramPin::approve(&wrapper).unwrap());
|
||||
server.target = Some(ide_language::ServerTarget {
|
||||
id: "test-target".into(),
|
||||
origin: None,
|
||||
project_root: root.clone(),
|
||||
container_root: "/container".into(),
|
||||
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), "/container"),
|
||||
});
|
||||
let expected =
|
||||
format!("argv: {:?}", std::iter::once(&server.command).chain(server.args.iter()).collect::<Vec<_>>());
|
||||
let mut language: LanguageConfig = toml::from_str("id='mock'\nname='Mock'\nextensions=['mock']").unwrap();
|
||||
language.language_servers = vec![server];
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let manager = LspManager::new(
|
||||
root.clone(),
|
||||
Arc::new(LanguageRegistry::from_configs(vec![language])),
|
||||
Arc::new(move |event: IdeEvent| sink_events.lock().push(event)),
|
||||
);
|
||||
manager.did_open(DocId::next(), &root.join("main.mock"), &LanguageId::new("mock"), 1, "x");
|
||||
let message = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Some(message) = events.lock().iter().find_map(|event| match event {
|
||||
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
|
||||
_ => None,
|
||||
}) {
|
||||
break message;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(message.contains("exited with code 37"), "{message}");
|
||||
assert!(message.contains("stderr-59"), "{message}");
|
||||
assert!(message.contains(&expected), "{message}");
|
||||
assert!(!message.contains("stderr-0\n"), "{message}");
|
||||
manager.shutdown_all().await;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ fn requests(message: &Value) -> Vec<Value> {
|
||||
async fn post(discovery: &Path, project: &Path, message: &Value, scope: &str) -> Result<(StatusCode, Bytes), String> {
|
||||
let not_running = || {
|
||||
format!(
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Agents › MCP Server",
|
||||
"OpenIDEA's MCP server is not running. Start OpenIDEA, open {} in it, and turn on Settings › Tools › MCP Server",
|
||||
project.display()
|
||||
)
|
||||
};
|
||||
|
||||
@@ -156,6 +156,7 @@ pub enum PluginTestEvent {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunTargetOperation {
|
||||
Run,
|
||||
Debug,
|
||||
Test,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,7 @@ pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::{
|
||||
MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real,
|
||||
resolve_writable_in_root,
|
||||
};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
|
||||
|
||||
@@ -419,21 +419,6 @@ pub trait HostApi: Send + Sync {
|
||||
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
|
||||
/// Performs `request` (its host is granted), filling in secret placeholders.
|
||||
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
|
||||
/// The `net:ask` answer for a request to HTTPS host `host` of this plugin
|
||||
/// (`subject`): `Some(true)` allowed and remembered (a host the user
|
||||
/// already allowed connects without asking again, also in the background),
|
||||
/// `Some(false)` allowed for this request only, `None` refused — also when
|
||||
/// nobody could be asked (`client` is `None` outside commands and native view actions).
|
||||
fn net_ask(
|
||||
&self,
|
||||
subject: &ide_extension::ledger::Subject,
|
||||
client: Option<&str>,
|
||||
host: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<bool>, HostError> {
|
||||
let _ = (subject, client, host, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
fn secret_is_set(&self, name: &str) -> bool;
|
||||
|
||||
/// Stores or deletes a secret declared by this plugin; never returns its value.
|
||||
@@ -811,12 +796,10 @@ pub struct StoreData {
|
||||
stderr_seen: usize,
|
||||
pub(crate) plugin_id: String,
|
||||
pub(crate) granted: Vec<Capability>,
|
||||
pub(crate) subject: ide_extension::ledger::Subject,
|
||||
pub(crate) host: Arc<dyn HostApi>,
|
||||
pub(crate) invocation: Invocation,
|
||||
/// The running call is a command or an event.
|
||||
pub(crate) interactive: bool,
|
||||
pub(crate) net_interactive: bool,
|
||||
/// Editor window for pick, pick-many, input and confirm during action resolution.
|
||||
pub(crate) action_prompt_client: Option<String>,
|
||||
/// Tasks begun by the running call and not ended yet.
|
||||
@@ -930,9 +913,6 @@ pub struct RuntimeSetup {
|
||||
pub version: String,
|
||||
/// Capabilities granted to the plugin.
|
||||
pub granted: Vec<Capability>,
|
||||
/// Whose grant this is (installed id, or the package directory of a
|
||||
/// linked or project plugin): what `net:ask` answers are remembered for.
|
||||
pub subject: ide_extension::ledger::Subject,
|
||||
pub host: Arc<dyn HostApi>,
|
||||
pub limits: Limits,
|
||||
}
|
||||
@@ -1042,11 +1022,9 @@ impl Runtime {
|
||||
stderr_seen: 0,
|
||||
plugin_id: setup.plugin_id,
|
||||
granted: setup.granted,
|
||||
subject: setup.subject,
|
||||
host: setup.host,
|
||||
invocation: Invocation::default(),
|
||||
interactive: false,
|
||||
net_interactive: false,
|
||||
action_prompt_client: None,
|
||||
open_tasks: Vec::new(),
|
||||
last_report: None,
|
||||
@@ -1196,6 +1174,7 @@ impl Runtime {
|
||||
let launch = wit_rtv::LaunchInfo {
|
||||
operation: match launch.operation {
|
||||
crate::RunTargetOperation::Run => wit_rtv::Operation::Run,
|
||||
crate::RunTargetOperation::Debug => wit_rtv::Operation::Debug,
|
||||
crate::RunTargetOperation::Test => wit_rtv::Operation::Test,
|
||||
},
|
||||
program: launch.program,
|
||||
@@ -1658,7 +1637,6 @@ impl Runtime {
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
store.data_mut().net_interactive = true;
|
||||
let r = e.plugin.openidea_plugin_commands().call_run(
|
||||
&mut *store,
|
||||
id,
|
||||
@@ -1784,12 +1762,10 @@ impl Runtime {
|
||||
if self.exports.views.is_none() {
|
||||
return Err(CallError::missing_export("view-provider"));
|
||||
}
|
||||
let net_interactive = !matches!(&event, ViewEvent::Message { .. });
|
||||
let event = event_to_wit_view(event);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
store.data_mut().interactive = true;
|
||||
store.data_mut().net_interactive = net_interactive;
|
||||
let views = e.views.as_ref().expect("checked");
|
||||
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
@@ -1841,7 +1817,6 @@ impl Runtime {
|
||||
let data = self.store.data_mut();
|
||||
data.invocation = Invocation::default();
|
||||
data.interactive = false;
|
||||
data.net_interactive = false;
|
||||
data.action_prompt_client = None;
|
||||
// Tasks don't outlive the call that began them.
|
||||
for id in std::mem::take(&mut data.open_tasks) {
|
||||
|
||||
@@ -117,45 +117,6 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Resolves a content write's destination, including existing and dangling
|
||||
/// symlinks in the final component. Entry operations use [`resolve_new_in_root`].
|
||||
pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
let real = resolve_write_target(&joined, 40).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
fn resolve_write_target(path: &Path, links_left: usize) -> Result<PathBuf, std::io::Error> {
|
||||
match dunce::canonicalize(path) {
|
||||
Ok(real) => return Ok(real),
|
||||
Err(error) => match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_symlink() => {
|
||||
if links_left == 0 {
|
||||
return Err(std::io::Error::other("too many symbolic links"));
|
||||
}
|
||||
let target = std::fs::read_link(path)?;
|
||||
let target = if target.is_absolute() { target } else { path.parent().unwrap().join(target) };
|
||||
return resolve_write_target(&target, links_left - 1);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
_ => return Err(error),
|
||||
},
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| std::io::Error::other("no directory to put it in"))?;
|
||||
let mut real = resolve_write_target(parent, links_left)?;
|
||||
match path.components().next_back() {
|
||||
Some(std::path::Component::ParentDir) => {
|
||||
real.pop();
|
||||
}
|
||||
Some(std::path::Component::Normal(name)) => real.push(name),
|
||||
_ => {}
|
||||
}
|
||||
if real != path { resolve_write_target(&real, links_left) } else { Ok(real) }
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -169,19 +130,6 @@ pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
|
||||
}
|
||||
|
||||
/// Reads the bytes of a file that `resolve_in_root` already approved: binary
|
||||
/// content is fine, the size cap is the same as [`read_text`]'s.
|
||||
pub fn read_bytes(real: &Path) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", real.display()));
|
||||
}
|
||||
if meta.len() > MAX_READ_BYTES {
|
||||
return Err(format!("{}: file is larger than {} MiB", real.display(), MAX_READ_BYTES >> 20));
|
||||
}
|
||||
std::fs::read(real).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Why a read outside the project was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
@@ -246,9 +194,9 @@ pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Re
|
||||
/// The real path `path` would be written to, when an `fs.write:<path>` grant
|
||||
/// covers it.
|
||||
///
|
||||
/// Missing paths and dangling links are resolved to their target location;
|
||||
/// an existing file (or link) is followed first. The destination's directory
|
||||
/// must exist.
|
||||
/// A file that does not exist yet cannot be canonicalized, so its directory is
|
||||
/// canonicalized instead and the name appended; an existing file (or link) is
|
||||
/// followed first, so a link cannot be used to write outside the grant.
|
||||
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
|
||||
resolve(granted, path, Wanted::Write)
|
||||
}
|
||||
@@ -293,14 +241,12 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
|
||||
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
|
||||
}
|
||||
|
||||
/// Where `asked` would be created, including a dangling link's target.
|
||||
/// Where `asked` would be created: its directory, resolved, plus its name.
|
||||
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
|
||||
let real = resolve_write_target(asked, 40).map_err(|e| e.to_string())?;
|
||||
let parent = real.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
if !parent.is_dir() {
|
||||
return Err("its directory does not exist".into());
|
||||
}
|
||||
Ok(real)
|
||||
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
|
||||
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
|
||||
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
|
||||
Ok(dir.join(name))
|
||||
}
|
||||
|
||||
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
|
||||
@@ -441,82 +387,6 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
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("inside.txt"), "before").unwrap();
|
||||
for (name, target) in [
|
||||
("outside", outside.clone()),
|
||||
("dangling-outside", tmp.path().join("missing.txt")),
|
||||
("inside", PathBuf::from("inside.txt")),
|
||||
("dangling-inside", PathBuf::from("new/deep.txt")),
|
||||
("chain", PathBuf::from("dangling-outside")),
|
||||
("cycle", PathBuf::from("cycle")),
|
||||
] {
|
||||
symlink(target, root.join(name)).unwrap();
|
||||
}
|
||||
for name in ["outside", "dangling-outside", "chain"] {
|
||||
assert!(resolve_writable_in_root(&root, name).unwrap_err().contains("outside the project root"));
|
||||
assert!(
|
||||
resolve_writable_in_root(&root, &format!("missing/../{name}"))
|
||||
.unwrap_err()
|
||||
.contains("outside the project root")
|
||||
);
|
||||
}
|
||||
assert!(resolve_writable_in_root(&root, "cycle").is_err());
|
||||
let inside = resolve_writable_in_root(&root, "inside").unwrap();
|
||||
assert_eq!(inside, root.join("inside.txt"));
|
||||
std::fs::write(inside, "after").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(root.join("inside.txt")).unwrap(), "after");
|
||||
assert_eq!(resolve_writable_in_root(&root, "dangling-inside").unwrap(), root.join("new/deep.txt"));
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
assert!(!tmp.path().join("missing.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_grants_confine_dangling_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("granted");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let grant = Capability::FsWrite(strip_verbatim(&root));
|
||||
symlink("../missing.txt", root.join("outside")).unwrap();
|
||||
symlink("new.txt", root.join("inside")).unwrap();
|
||||
assert!(matches!(
|
||||
resolve_writable(std::slice::from_ref(&grant), root.join("outside").to_str().unwrap()),
|
||||
Err(Refusal::Denied(_))
|
||||
));
|
||||
assert_eq!(resolve_writable(&[grant], root.join("inside").to_str().unwrap()).unwrap(), root.join("new.txt"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn entry_operations_do_not_follow_outside_symlinks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "unchanged").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
|
||||
let from = resolve_new_in_root(&root, "link").unwrap();
|
||||
let to = resolve_new_in_root(&root, "renamed").unwrap();
|
||||
std::fs::rename(&from, &to).unwrap();
|
||||
assert!(!from.try_exists().unwrap());
|
||||
assert!(std::fs::symlink_metadata(&to).unwrap().is_symlink());
|
||||
std::fs::remove_file(resolve_new_in_root(&root, "renamed").unwrap()).unwrap();
|
||||
assert!(std::fs::symlink_metadata(&to).is_err());
|
||||
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
@@ -541,25 +411,6 @@ mod tests {
|
||||
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_the_exact_bytes_up_to_the_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// The bytes binary files carry survive, where read_text refuses them.
|
||||
let binary = [0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe, 0x1b];
|
||||
std::fs::write(tmp.path().join("b.bin"), binary).unwrap();
|
||||
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
|
||||
assert_eq!(read_bytes(&p).unwrap(), binary);
|
||||
// A directory is not a file; the size cap is read_text's.
|
||||
assert!(read_bytes(&resolve_in_root(tmp.path(), ".").unwrap()).unwrap_err().contains("not a file"));
|
||||
let big = tmp.path().join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; (MAX_READ_BYTES + 1) as usize]).unwrap();
|
||||
let err = read_bytes(&big).unwrap_err();
|
||||
assert!(err.contains("larger than"), "{err}");
|
||||
let at_cap = tmp.path().join("at-cap.bin");
|
||||
std::fs::write(&at_cap, vec![7u8; MAX_READ_BYTES as usize]).unwrap();
|
||||
assert_eq!(read_bytes(&at_cap).unwrap().len(), MAX_READ_BYTES as usize);
|
||||
}
|
||||
|
||||
/// The grant a manifest author would write for `dir`: the plain path, no
|
||||
/// `\\?\` prefix.
|
||||
fn grant_for(dir: &Path) -> Capability {
|
||||
|
||||
@@ -94,10 +94,6 @@ pub enum Prompt {
|
||||
Confirm { title: String, message: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PickDirectory { title: String, initial: Option<String> },
|
||||
/// The host's own question under `net:ask` (never plugin-initiated): the
|
||||
/// plugin reached an HTTPS host its manifest does not list.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
NetPermission { host: String, session_only: bool },
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
@@ -441,13 +437,7 @@ pub fn net_host(url: &str) -> Result<String, String> {
|
||||
return Err(format!("unsupported scheme {:?}", parsed.scheme()));
|
||||
}
|
||||
match parsed.host() {
|
||||
Some(url::Host::Domain(host)) => {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
if host.is_empty() || host.contains('*') {
|
||||
return Err("URL must name one exact host".into());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()),
|
||||
Some(url::Host::Ipv4(host)) => Ok(host.to_string()),
|
||||
Some(url::Host::Ipv6(host)) => Ok(host.to_string()),
|
||||
None => Err("URL has no host".into()),
|
||||
@@ -489,21 +479,8 @@ mod tests {
|
||||
net_capability("http://example.com/").unwrap(),
|
||||
ide_extension::Capability::NetHttp("example.com".into())
|
||||
);
|
||||
for (url, host) in [
|
||||
("https://EXAMPLE.com.:8443/", "example.com"),
|
||||
("https://bücher.example/", "xn--bcher-kva.example"),
|
||||
("https://xn--bcher-kva.example/", "xn--bcher-kva.example"),
|
||||
("https://127.1/", "127.0.0.1"),
|
||||
("https://[0:0:0:0:0:0:0:1]:8443/", "::1"),
|
||||
("https://sub.example.com/", "sub.example.com"),
|
||||
] {
|
||||
assert_eq!(net_host(url).unwrap(), host);
|
||||
}
|
||||
assert_ne!(net_host("https://аpi.example/").unwrap(), net_host("https://api.example/").unwrap());
|
||||
assert!(net_host("file:///etc/passwd").is_err());
|
||||
assert!(net_host("example.com").is_err());
|
||||
assert!(net_host("https://*.example.com/").is_err());
|
||||
assert!(net_host("https://./").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -322,23 +322,6 @@ impl StoreData {
|
||||
answer.map_err(to_wit)
|
||||
}
|
||||
|
||||
/// The user's answer for a request to `host` under `net:ask`:
|
||||
/// `Some(_)` allows it (`true`: the host is remembered). `None` when
|
||||
/// nobody could be asked or the user refused. Commands and view actions pass
|
||||
/// their window; background and scheduled calls ask nobody, so only an
|
||||
/// already-allowed host connects there.
|
||||
fn ask_net(&mut self, host: &str) -> Result<Option<bool>, wit::Error> {
|
||||
let client = self.net_interactive.then(|| self.invocation.client.clone()).flatten();
|
||||
let subject = self.subject.clone();
|
||||
let started = Instant::now();
|
||||
let answer = self.host.net_ask(&subject, client.as_deref(), host, &self.cancel.token());
|
||||
// The wait doesn't count against the call's wall-clock budget.
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
answer.map_err(to_wit)
|
||||
}
|
||||
|
||||
fn pick_prompt(
|
||||
&mut self,
|
||||
title: String,
|
||||
@@ -696,22 +679,6 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<Vec<u8>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
// The bytes on disk: an unsaved text buffer does not apply, binary files are the point.
|
||||
Ok(sandbox::read_bytes(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -794,7 +761,7 @@ impl workspace::HostProject for StoreData {
|
||||
return Ok(Err(too_much_to_write(&path)));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_writable_in_root(&root, &path) {
|
||||
let real = match sandbox::resolve_new_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
@@ -1302,26 +1269,8 @@ impl net::Host for StoreData {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
let mut grants = self.granted.clone();
|
||||
if let Err(e) = self.require(capability.clone()) {
|
||||
// `net:ask`: an HTTPS (or loopback) host the manifest does not list
|
||||
// may ask the user — commands and view actions from a window only. Plain
|
||||
// HTTP elsewhere always needs `net.http:<host>`.
|
||||
let askable =
|
||||
matches!(&capability, Capability::Net(_)) && self.granted.iter().any(|g| g.covers(&Capability::NetAsk));
|
||||
let allowed = match &capability {
|
||||
Capability::Net(host) if askable => match self.ask_net(host) {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
// A cancelled or failed question is not a refusal.
|
||||
Err(e) => return Ok(Err(e)),
|
||||
},
|
||||
_ => false,
|
||||
};
|
||||
if !allowed {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
grants.push(capability.clone());
|
||||
if let Err(e) = self.require(capability) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
|
||||
&& let Err(e) = self.require(Capability::Secrets)
|
||||
@@ -1338,7 +1287,7 @@ impl net::Host for StoreData {
|
||||
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
|
||||
body: req.body,
|
||||
timeout: wanted.min(self.remaining()),
|
||||
grants,
|
||||
grants: self.granted.clone(),
|
||||
};
|
||||
let token = self.cancel.token();
|
||||
Ok(self
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
45ac83943d767245797a22a108169867e66791ca6f0a7e2bc70ca4ebfda7974a
|
||||
023542d758cd359aa4d1282b95ddd2edca9fcf746862700705dd637b3e524571
|
||||
Loaded 100 of 788 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача