Compare commits

..
Author SHA1 Message Date
l0ng-ai 51e2b19ddc docs(agents): scale local verification to change risk 2026-11-07 14:10:42 +08:00
2220 changed files with 18454 additions and 217391 deletions

No files matched your search

+8
View File
@@ -9,6 +9,14 @@ The reported case is one sample of a defect class. A fix that only makes the
screenshot go away is not done: the next question is always "does this break for other
languages / platforms / entry points too?". Answer that before anyone asks.
Scale the workflow to the risk. For an obvious, low-risk CSS or copy fix, inspect
the cause and nearby shared styles, make the focused edit, check formatting and
verify the affected UI. Run existing targeted tests only when they cover a plausible
regression; do not add implementation-mirroring tests or default to full-suite tests,
E2E, builds, typechecking or dependency analysis. Reuse completed checks while the
diff is unchanged. A brief finding and verification note is enough; skip the formal
sibling table. Follow `AGENTS.md` for broader changes and create a PR when requested.
## 1. Read and reproduce
- `gh issue view <n> --comments` (repo `l0ng-ai/openidea`). Read attached logs/screenshots
-3
View File
@@ -3,6 +3,3 @@ contact_links:
- name: Download the latest release
url: https://github.com/l0ng-ai/openidea/releases/latest
about: Many problems are already fixed. Please check the latest version before reporting.
- name: Report a security vulnerability
url: https://github.com/l0ng-ai/openidea/security/advisories/new
about: Please report security problems privately, not in a public issue.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release-build.yml):
# The remote development server ships as a static Linux binary (release.yml):
# keep that build working.
server:
name: Remote server (linux musl)
+2 -17
View File
@@ -47,7 +47,6 @@ env:
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -55,9 +54,8 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -76,12 +74,6 @@ jobs:
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
- if: matrix.language == 'c' || matrix.language == 'cpp'
run: |
sudo apt-get update
sudo apt-get install -y gdb
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
@@ -116,13 +108,6 @@ jobs:
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
- if: matrix.language == 'php'
uses: shivammathur/setup-php@v2
with: { php-version: "8.5", coverage: xdebug, tools: composer }
- if: matrix.language == 'php'
run: npm install --global "intelephense@$INTELEPHENSE"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
-119
View File
@@ -1,119 +0,0 @@
name: Nightly
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
# version, so remote development finds the server of that version. The `nightly` release holds
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
# nothing newer stops there. Only the latest builds are kept.
on:
schedule:
- cron: "17 2,14 * * *"
workflow_dispatch:
inputs:
force:
description: "Build even if nothing is newer than the last nightly"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
jobs:
prepare:
permissions:
actions: read
contents: write
if: github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.commit.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
release_id: ${{ steps.release.outputs.id }}
steps:
- id: commit
env:
FORCE: ${{ inputs.force }}
run: |
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
--json headSha --jq '.[0].headSha // ""')"
if [ -z "$sha" ]; then
echo "::notice::no main commit has passed CI"
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
if: steps.commit.outputs.sha != ''
with: { ref: "${{ steps.commit.outputs.sha }}" }
- uses: actions/setup-node@v5
if: steps.commit.outputs.sha != ''
with: { node-version: 22 }
- uses: dtolnay/rust-toolchain@stable
if: steps.commit.outputs.sha != ''
- id: release
if: steps.commit.outputs.sha != ''
env:
SHA: ${{ steps.commit.outputs.sha }}
run: |
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
tag="v$version"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: $tag"
git tag "$tag"
git push origin "$tag"
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "id=$id" >> "$GITHUB_OUTPUT"
binaries:
permissions:
contents: write
needs: prepare
if: needs.prepare.outputs.tag != ''
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
release_id: ${{ needs.prepare.outputs.release_id }}
secrets: inherit
publish:
permissions:
contents: write
needs: [prepare, binaries]
runs-on: ubuntu-latest
env:
TAG: ${{ needs.prepare.outputs.tag }}
SHA: ${{ needs.prepare.outputs.sha }}
KEEP: 14
steps:
- name: Publish the build
run: gh release edit "$TAG" --tag "$TAG" --draft=false
- name: Point the updater at it
run: |
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
gh release view nightly >/dev/null 2>&1 ||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
--notes "The update manifest of the nightly builds, and the commit last built."
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
# Failed builds' drafts go too, once they are this old.
- name: Delete old builds
run: |
gh release list --limit 200 --json tagName,createdAt --jq \
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
while read -r tag; do
gh release delete "$tag" --cleanup-tag --yes
done
-2
View File
@@ -22,8 +22,6 @@ env:
jobs:
merge:
# The schedule is for this repository's release; forks can still run it by hand.
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
steps:
- name: Merge the community index
-262
View File
@@ -1,262 +0,0 @@
name: Release build
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
release_id: { type: string, required: true }
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
TAG: ${{ inputs.tag }}
jobs:
plugin-keys:
permissions: {}
runs-on: ubuntu-latest
outputs:
keys: ${{ steps.keys.outputs.keys }}
steps:
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
build:
permissions:
contents: write
needs: plugin-keys
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ inputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
# Release assets have no spaces: GitHub stored the first upload with dots.
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
cp "$dmg" "$asset"
gh release upload "$TAG" "$asset" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
permissions:
contents: write
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ inputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
const nightly = tag.includes("-nightly.");
const windows = (arch, msi, nsis) => nightly
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
+229 -24
View File
@@ -1,7 +1,7 @@
name: Release
on:
push:
tags: ["v*", "!v*-nightly.*"]
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
@@ -9,7 +9,7 @@ on:
required: true
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
@@ -23,12 +23,10 @@ env:
jobs:
# Creates (or reuses) one draft release that every platform build uploads into.
create-release:
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
@@ -49,6 +47,20 @@ jobs:
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: plugin-keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
with:
@@ -62,26 +74,161 @@ jobs:
core.setOutput("id", existing.id);
return;
}
// Nightly builds are prereleases: the notes span stable release to stable release.
const previous = releases.find((r) => !r.draft && !r.prerelease);
const { data: notes } = await github.rest.repos.generateReleaseNotes({
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
});
const { data } = await github.rest.repos.createRelease({
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
draft: true, prerelease: tag.includes("-"), body: notes.body,
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
});
core.setOutput("id", data.id);
binaries:
permissions:
contents: write
build:
needs: create-release
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ github.event.inputs.tag || github.ref_name }}
release_id: ${{ needs.create-release.outputs.release_id }}
secrets: inherit
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
needs: create-release
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
@@ -93,8 +240,6 @@ jobs:
# openidea-plugin tool for Linux x64, which that workflow and the registry's
# checks run.
plugins:
permissions:
contents: write
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
@@ -158,10 +303,70 @@ jobs:
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
"$out/openidea-plugin-linux-x86_64.gz" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
permissions:
contents: write
needs: [create-release, binaries, plugins]
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
+1 -1
View File
@@ -12,7 +12,7 @@
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"plugins/themes.md",
"docs/plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
"fixtures",
+8 -4
View File
@@ -21,13 +21,11 @@ Visual style: dark, low-contrast greys, one cool accent
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
## Rules
@@ -86,7 +84,13 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
## Before opening a PR
Run what CI runs for the parts you touched:
Scale local verification to the change. For low-risk CSS-only or copy-only changes,
check the diff and formatting of touched files; use existing targeted tests when they
cover a plausible regression, and verify visible changes in the running app. Reuse
completed checks when the diff has not changed. Do not default to full tests, E2E,
builds, typechecking or dependency analysis for these changes; CI runs the full suite.
For behaviour, shared infrastructure or broader changes, run the applicable CI checks:
- Rust: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
`cargo nextest run --workspace`. After changing IPC types, run `pnpm gen:bindings` and
Generated
+183 -304
View File
File diff suppressed because it is too large. Load diff
+5 -11
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.41"
version = "0.1.38"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -42,11 +42,9 @@ ide-fonts = { path = "crates/ide-fonts" }
ide-ssh = { path = "crates/ide-ssh" }
ide-remote = { path = "crates/ide-remote" }
ide-host = { path = "crates/ide-host" }
ide-mcp = { path = "crates/ide-mcp" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
@@ -82,7 +80,7 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
zip = { version = "9", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
@@ -103,11 +101,9 @@ portable-pty = "0.9"
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "http2", "charset", "socks", "gzip", "deflate", "brotli"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
rustls-platform-verifier = "0.7"
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
oracledb = "=26.0.0-beta.5"
oracle = { version = "0.6", default-features = false }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
@@ -133,9 +129,7 @@ debug = false
[profile.release]
codegen-units = 1
# Thin LTO: fat LTO's whole-program link outgrew the 16 GB release runners
# (Linux arm64 was killed mid-link from v0.1.39 on).
lto = "thin"
lto = true
opt-level = 3
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
+14 -6
View File
@@ -12,8 +12,6 @@
<p align="center">
<a href="https://github.com/l0ng-ai/openidea/releases/latest"><b>Download</b></a>
&nbsp;·&nbsp;
<a href="https://openidea.sh">Docs</a>
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
@@ -27,7 +25,9 @@ OpenIDEA brings the IntelliJ IDEA way of working — its keymap, its tool window
its search-everything habits — to a native app with a Rust core.
Editor, code insight, debugger, Git, terminal, database and HTTP clients,
for 60+ languages. Builds for macOS, Windows and Linux.
for 60+ languages. Builds for macOS, Windows and Linux. On Windows, if you'd
rather skip the installer, each release also ships a portable zip — unpack it
and run `openidea.exe`.
## Benchmark
@@ -56,7 +56,7 @@ themes, file icons and languages. They run as sandboxed WebAssembly components;
themes, icon themes and languages need no code at all.
Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](https://openidea.sh/en/plugins) and
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
@@ -64,12 +64,20 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src=".github/assets/wechat-group.png" alt="WeChat group QR code" width="200">
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
### Contributors
<a href="https://github.com/l0ng-ai/openidea/graphs/contributors">
<img src="https://contrib.rocks/image?repo=l0ng-ai/openidea&max=100" alt="OpenIDEA contributors">
</a>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
Database tool window notes, including Oracle Instant Client setup, are in
[`docs/databases.md`](docs/databases.md).
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
-26
View File
@@ -1,26 +0,0 @@
# Security policy
## Reporting a vulnerability
Please do not report security problems in public issues, pull requests or discussions.
Report them privately through GitHub:
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
the report.
Include what you can of:
- the affected version, platform, and whether remote development (`openidea-server`)
or plugins are involved;
- steps to reproduce, or a proof of concept;
- the impact you expect, and any fix you suggest.
We will acknowledge the report, keep you updated in the advisory, and agree on a
disclosure date with you. Fixes are released as a new version, and the advisory is
published once users can update.
## Supported versions
Security fixes go into the latest release. Please check that the problem still occurs
there before reporting.
+2 -13
View File
@@ -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
+3 -2
View File
@@ -9,7 +9,6 @@ license.workspace = true
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
ide-tools = { path = "../ide-tools" }
@@ -22,5 +21,7 @@ tracing.workspace = true
parking_lot.workspace = true
dunce.workspace = true
uuid.workspace = true
tempfile.workspace = true
which = "8"
[dev-dependencies]
tempfile.workspace = true
+1 -34
View File
@@ -27,29 +27,6 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use tokio::sync::oneshot;
/// ACP's error code for a request that needs the user to sign in.
pub(crate) const AUTH_REQUIRED: i64 = -32000;
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
if code == AUTH_REQUIRED {
return true;
}
let lower = message.to_lowercase();
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
words.iter().any(|w| {
*w == "auth"
|| w.starts_with("authenticat")
|| w.starts_with("authoriz")
|| w.starts_with("unauthenticat")
|| w.starts_with("unauthoriz")
|| w.starts_with("oauth")
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|| words
.windows(2)
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
}
/// The capabilities this client offers, in v1 form.
fn client_capabilities() -> Value {
json!({
@@ -92,11 +69,6 @@ pub(crate) struct Negotiated {
}
impl Negotiated {
pub fn mcp_stdio(&self) -> bool {
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
}
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
/// v2 response has `info`.
@@ -125,7 +97,7 @@ impl Negotiated {
"audio": has(prompt, "audio"),
"embeddedContext": has(prompt, "embeddedContext"),
},
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
});
if session.is_some() {
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
@@ -164,11 +136,6 @@ impl Negotiated {
self.capabilities.pointer(&format!("/promptCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
}
/// `mcpCapabilities.<name>` (`http`, `sse`).
pub fn mcp(&self, name: &str) -> bool {
self.capabilities.pointer(&format!("/mcpCapabilities/{name}")).and_then(Value::as_bool) == Some(true)
}
/// `sessionCapabilities.<name>` (`fork`, `resume`, `close`, `list`, `delete`).
pub fn session(&self, name: &str) -> bool {
self.capabilities.pointer(&format!("/sessionCapabilities/{name}")).is_some_and(|v| !v.is_null())
-46
View File
@@ -16,42 +16,6 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
#[test]
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
for (code, message, expected) in [
(AUTH_REQUIRED, "This account is not eligible.", true),
(codes::INTERNAL_ERROR, "Auth required", true),
(codes::INTERNAL_ERROR, "Authentication failed", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
(codes::INTERNAL_ERROR, "Not authenticated", true),
(codes::INTERNAL_ERROR, "Authorization failed", true),
(codes::INTERNAL_ERROR, "Authorize your account", true),
(codes::INTERNAL_ERROR, "Unauthenticated", true),
(codes::INTERNAL_ERROR, "Unauthentication error", true),
(codes::INTERNAL_ERROR, "Unauthorized", true),
(codes::INTERNAL_ERROR, "Unauthorization error", true),
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
(codes::INTERNAL_ERROR, "You are not logged in", true),
(codes::INTERNAL_ERROR, "You are logged out", true),
(codes::INTERNAL_ERROR, "Please sign in", true),
(codes::INTERNAL_ERROR, "Please log in", true),
(codes::INTERNAL_ERROR, "Login required", true),
(codes::INTERNAL_ERROR, "author", false),
(codes::INTERNAL_ERROR, "authors", false),
(codes::INTERNAL_ERROR, "authority", false),
(codes::INTERNAL_ERROR, "authored", false),
(codes::INTERNAL_ERROR, "reauthenticate", false),
(codes::INTERNAL_ERROR, "preauthorized", false),
(codes::INTERNAL_ERROR, "signal input failed", false),
(codes::INTERNAL_ERROR, "catalog index failed", false),
(codes::INTERNAL_ERROR, "login succeeded", false),
] {
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
}
}
/// The messages of a fixture going `dir` (`in` or `out`).
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
fixture
@@ -563,13 +527,3 @@ async fn v1_requests_are_sent_in_v2() {
.collect();
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
}
#[test]
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
assert!(
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
.mcp_stdio()
);
}
+13 -106
View File
@@ -2,8 +2,7 @@
//! the agent reports them over ACP.
use crate::model::{
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
AgentUsage,
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
};
use serde_json::{Value, json};
@@ -36,32 +35,20 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
});
}
/// Whether the current settings approve tool requests without asking: a bypass mode,
/// an allow-all permissions option, or a bypass approval policy.
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
let selected = |category: &str| {
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
};
selected("mode").is_some_and(is_bypass_mode)
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
}
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
let value = option.values.iter().find(|v| {
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
})?;
Some((option, value))
}
fn is_bypass_mode(mode: &str) -> bool {
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
let lower = mode.to_lowercase();
let m: String =
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
}
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
/// plus the older `modes` / `models` fields for what those don't cover.
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
@@ -146,22 +133,15 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
}
/// `available_commands_update`.
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
update
.get("availableCommands")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| {
let name = c.get("name")?.as_str()?.trim_start_matches('/');
let source = if provider == "codex-acp" && name.starts_with('$') {
AgentCommandSource::Skill
} else {
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
};
Some(AgentCommand {
name: name.to_owned(),
source,
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
description: str_of(c, "description").unwrap_or_default(),
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
})
@@ -287,85 +267,12 @@ mod tests {
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
}
#[test]
fn selected_bypass_settings_approve_without_asking() {
let full_access = options_of(&json!({
"modes": {"currentModeId": "full-access", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(bypass_approvals(&full_access));
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
let asking = options_of(&json!({
"modes": {"currentModeId": "ask", "availableModes": [
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
]}
}));
assert!(!bypass_approvals(&asking));
assert_eq!(
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
Some("full-access"),
"the offered bypass mode stays available"
);
let permissions = |current: &str| {
options_of(&json!({"configOptions": [{
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
}]}))
};
assert!(bypass_approvals(&permissions("on")));
assert!(bypass_approvals(&permissions("TRUE")));
assert!(!bypass_approvals(&permissions("off")));
let approval = options_of(&json!({"configOptions": [{
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
}]}));
assert!(bypass_approvals(&approval));
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
let url_mode = options_of(&json!({
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
}));
assert!(bypass_approvals(&url_mode));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
{"name": "review", "source": "skill"},
{"name": "$lint"},
{"name": "compact"}
]});
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
assert_eq!(
sources("codex-acp"),
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
);
assert_eq!(
sources("custom"),
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
);
let old: AgentCommand =
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
assert_eq!(old.source, AgentCommandSource::Command);
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
}
#[test]
fn commands_and_usage() {
let cmds = commands_of(
&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}),
"claude",
);
assert_eq!(cmds[0].source, AgentCommandSource::Command);
let cmds = commands_of(&json!({"availableCommands": [
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
]}));
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
File diff suppressed because it is too large. Load diff
-507
View File
@@ -1,507 +0,0 @@
//! Model Context Protocol (MCP) server configurations stored in OpenIDEA's own settings
//! (global `settings.toml` and project `.openidea/settings.toml`), passed to ACP agents in
//! `session/new`, `session/load`, and `session/fork`, plus one-way import from `.mcp.json` /
//! `.vscode/mcp.json`.
use crate::acp::Negotiated;
use crate::{AgentMcpServer, McpKeyValue, McpScope, McpServer, McpServerInput, McpTransport};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
/// Stable ID for a server in a given scope (`"user::<name>"` or `"workspace::<name>"`).
pub fn server_id(scope: McpScope, name: &str) -> String {
let prefix = match scope {
McpScope::User => "user",
McpScope::Workspace => "workspace",
};
format!("{prefix}::{name}")
}
/// Parses a server ID (`"user::<name>"` or `"workspace::<name>"`).
pub fn parse_server_id(id: &str) -> Option<(McpScope, &str)> {
let (prefix, name) = id.split_once("::")?;
if name.is_empty() {
return None;
}
match prefix {
"user" => Some((McpScope::User, name)),
"workspace" => Some((McpScope::Workspace, name)),
_ => None,
}
}
/// Converts the User (`settings.toml`) and Workspace (`.openidea/settings.toml`) MCP server maps
/// into the ordered list displayed in Settings › Agents › MCP Servers.
pub fn servers_from_settings(
user: &BTreeMap<String, AgentMcpServer>,
workspace: &BTreeMap<String, AgentMcpServer>,
) -> Vec<McpServer> {
user.iter()
.map(|(name, cfg)| to_mcp_server(McpScope::User, name, cfg))
.chain(workspace.iter().map(|(name, cfg)| to_mcp_server(McpScope::Workspace, name, cfg)))
.collect()
}
fn to_mcp_server(scope: McpScope, name: &str, cfg: &AgentMcpServer) -> McpServer {
McpServer {
id: server_id(scope, name),
name: name.to_owned(),
scope,
transport: cfg.transport,
enabled: cfg.enabled,
command: cfg.command.clone(),
args: cfg.args.clone(),
env: cfg.env.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
url: cfg.url.clone(),
headers: cfg.headers.iter().map(|(k, v)| McpKeyValue { name: k.clone(), value: v.clone() }).collect(),
agents: cfg.agents.clone(),
}
}
fn normalize_provider(id: &str) -> &str {
id.strip_suffix("-acp").unwrap_or(id)
}
fn matches_agent(agents: &[String], provider: &str) -> bool {
if agents.is_empty() {
return true;
}
let target = normalize_provider(provider);
agents.iter().any(|a| {
let trimmed = a.trim();
trimmed == provider || normalize_provider(trimmed) == target
})
}
/// Builds the ACP `mcpServers` array for `session/new`, `session/load`, and `session/fork`.
/// Workspace entries take precedence over User entries with the same name.
pub(crate) fn acp_mcp_servers(all: &[McpServer], provider: &str, init: &Negotiated) -> Vec<Value> {
let supports_stdio = init.mcp_stdio();
let supports_http = init.mcp("http");
let supports_sse = init.mcp("sse");
let mut seen = HashSet::new();
let mut servers = Vec::new();
let ordered =
all.iter().filter(|s| s.scope == McpScope::Workspace).chain(all.iter().filter(|s| s.scope == McpScope::User));
for srv in ordered {
if !srv.enabled || !matches_agent(&srv.agents, provider) || !seen.insert(srv.name.clone()) {
continue;
}
match srv.transport {
McpTransport::Stdio if supports_stdio => {
let Some(cmd) = srv.command.as_deref().filter(|c| !c.trim().is_empty()) else {
continue;
};
let env: Vec<Value> = srv.env.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"name": srv.name,
"command": cmd,
"args": srv.args,
"env": env,
}));
}
McpTransport::Http if supports_http => {
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
continue;
};
let headers: Vec<Value> =
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"type": "http",
"name": srv.name,
"url": url,
"headers": headers,
}));
}
McpTransport::Sse if supports_sse => {
let Some(url) = srv.url.as_deref().filter(|u| !u.trim().is_empty()) else {
continue;
};
let headers: Vec<Value> =
srv.headers.iter().map(|kv| json!({ "name": kv.name, "value": kv.value })).collect();
servers.push(json!({
"type": "sse",
"name": srv.name,
"url": url,
"headers": headers,
}));
}
_ => {}
}
}
servers
}
/// Applies a create or update operation to the User / Workspace MCP server maps.
pub fn apply_save(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
has_workspace: bool,
input: McpServerInput,
) -> Result<(), String> {
let name = input.name.trim();
if name.is_empty() {
return Err("Server name is required".into());
}
if input.scope == McpScope::Workspace && !has_workspace {
return Err("Open a project to configure workspace MCP servers".into());
}
let cfg = match input.transport {
McpTransport::Stdio => {
let cmd = input.command.as_deref().map(str::trim).unwrap_or_default();
if cmd.is_empty() {
return Err("Command is required for stdio MCP servers".into());
}
let env = input
.env
.into_iter()
.filter_map(|kv| {
let k = kv.name.trim();
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
})
.collect();
AgentMcpServer {
transport: McpTransport::Stdio,
enabled: input.enabled,
command: Some(cmd.to_owned()),
args: input.args,
env,
url: None,
headers: BTreeMap::new(),
agents: normalize_agents_list(input.agents),
}
}
McpTransport::Http | McpTransport::Sse => {
let url = input.url.as_deref().map(str::trim).unwrap_or_default();
if url.is_empty() {
return Err("URL is required for HTTP/SSE MCP servers".into());
}
let headers = input
.headers
.into_iter()
.filter_map(|kv| {
let k = kv.name.trim();
(!k.is_empty()).then(|| (k.to_owned(), kv.value))
})
.collect();
AgentMcpServer {
transport: input.transport,
enabled: input.enabled,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: Some(url.to_owned()),
headers,
agents: normalize_agents_list(input.agents),
}
}
};
if let Some(prev_id) = input.id.as_deref()
&& let Some((prev_scope, prev_name)) = parse_server_id(prev_id)
&& (prev_scope != input.scope || prev_name != name)
{
match prev_scope {
McpScope::User => {
user.remove(prev_name);
}
McpScope::Workspace => {
workspace.remove(prev_name);
}
}
}
match input.scope {
McpScope::User => {
user.insert(name.to_owned(), cfg);
}
McpScope::Workspace => {
workspace.insert(name.to_owned(), cfg);
}
}
Ok(())
}
/// Enables or disables a User or Workspace MCP server by ID.
pub fn apply_set_enabled(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
id: &str,
enabled: bool,
) -> Result<McpScope, String> {
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
let map = match scope {
McpScope::User => user,
McpScope::Workspace => workspace,
};
let srv = map.get_mut(name).ok_or_else(|| format!("MCP server `{name}` not found"))?;
srv.enabled = enabled;
Ok(scope)
}
/// Removes a User or Workspace MCP server by ID.
pub fn apply_remove(
user: &mut BTreeMap<String, AgentMcpServer>,
workspace: &mut BTreeMap<String, AgentMcpServer>,
id: &str,
) -> Result<McpScope, String> {
let (scope, name) = parse_server_id(id).ok_or_else(|| format!("MCP server `{id}` not found"))?;
let map = match scope {
McpScope::User => user,
McpScope::Workspace => workspace,
};
if map.remove(name).is_none() {
return Err(format!("MCP server `{name}` not found"));
}
Ok(scope)
}
/// Imports MCP servers from `<workspace_root>/.mcp.json` and `<workspace_root>/.vscode/mcp.json`
/// into `workspace` without modifying the external files. Returns the number of newly added servers.
pub fn apply_import(workspace: &mut BTreeMap<String, AgentMcpServer>, workspace_root: &Path) -> usize {
let mut added = 0;
for rel in [".mcp.json", ".vscode/mcp.json"] {
let path = workspace_root.join(rel);
for (name, srv) in read_external_file(&path) {
if let std::collections::btree_map::Entry::Vacant(e) = workspace.entry(name) {
e.insert(srv);
added += 1;
}
}
}
added
}
fn normalize_agents_list(agents: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for a in agents {
let trimmed = a.trim();
if !trimmed.is_empty() && !out.iter().any(|existing: &String| existing == trimmed) {
out.push(trimmed.to_owned());
}
}
out
}
fn read_external_file(path: &Path) -> BTreeMap<String, AgentMcpServer> {
let Ok(bytes) = std::fs::read(path) else {
return BTreeMap::new();
};
let Ok(root) = serde_json::from_slice::<Value>(&bytes) else {
return BTreeMap::new();
};
let Some(servers_obj) = root.get("mcpServers").or_else(|| root.get("servers")).and_then(Value::as_object) else {
return BTreeMap::new();
};
let mut out = BTreeMap::new();
for (name, def) in servers_obj {
let name = name.trim();
let Some(obj) = def.as_object().filter(|_| !name.is_empty()) else {
continue;
};
let disabled = obj.get("disabled").and_then(Value::as_bool).unwrap_or(false)
|| obj.get("enabled").and_then(Value::as_bool) == Some(false);
let type_str = obj.get("type").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
let url = obj
.get("url")
.or_else(|| obj.get("serverUrl"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let command =
obj.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned);
let transport = match type_str.as_str() {
"sse" => McpTransport::Sse,
"http" | "streamable-http" | "streamable_http" => McpTransport::Http,
"stdio" | "local" => McpTransport::Stdio,
_ if url.is_some() && command.is_none() => McpTransport::Http,
_ => McpTransport::Stdio,
};
match transport {
McpTransport::Stdio if command.is_none() => continue,
McpTransport::Http | McpTransport::Sse if url.is_none() => continue,
_ => {}
}
let args = obj
.get("args")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
out.insert(
name.to_owned(),
AgentMcpServer {
transport,
enabled: !disabled,
command,
args,
env: parse_key_values_map(obj.get("env")),
url,
headers: parse_key_values_map(obj.get("headers")),
agents: Vec::new(),
},
);
}
out
}
fn parse_key_values_map(val: Option<&Value>) -> BTreeMap<String, String> {
match val {
Some(Value::Object(map)) => map
.iter()
.filter_map(|(k, v)| {
let s = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => return None,
};
Some((k.clone(), s))
})
.collect(),
Some(Value::Array(arr)) => arr
.iter()
.filter_map(|item| {
let obj = item.as_object()?;
let name = obj.get("name")?.as_str()?.to_owned();
let value = obj.get("value")?.as_str()?.to_owned();
Some((name, value))
})
.collect(),
_ => BTreeMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crud_and_acp_filtering_by_scope_agent_and_transport() {
let mut user = BTreeMap::new();
let mut workspace = BTreeMap::new();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "shared".into(),
scope: McpScope::User,
transport: McpTransport::Stdio,
enabled: true,
command: Some("npx".into()),
args: vec!["-y".into(), "user-pkg".into()],
env: vec![McpKeyValue { name: "TOKEN".into(), value: "u1".into() }],
url: None,
headers: Vec::new(),
agents: Vec::new(),
},
)
.unwrap();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "shared".into(),
scope: McpScope::Workspace,
transport: McpTransport::Stdio,
enabled: true,
command: Some("npx".into()),
args: vec!["-y".into(), "workspace-pkg".into()],
env: Vec::new(),
url: None,
headers: Vec::new(),
agents: Vec::new(),
},
)
.unwrap();
apply_save(
&mut user,
&mut workspace,
true,
McpServerInput {
id: None,
name: "claude-http".into(),
scope: McpScope::User,
transport: McpTransport::Http,
enabled: true,
command: None,
args: Vec::new(),
env: Vec::new(),
url: Some("https://example.com/mcp".into()),
headers: vec![McpKeyValue { name: "Authorization".into(), value: "Bearer secret".into() }],
agents: vec!["claude".into()],
},
)
.unwrap();
let list = servers_from_settings(&user, &workspace);
assert_eq!(list.len(), 3);
// Negotiated with HTTP support: `claude` gets workspace `shared` + `claude-http`; `codex` only gets `shared`.
let init_http = Negotiated::from_initialize(&json!({
"agentCapabilities": { "mcpCapabilities": { "http": true, "sse": false } }
}));
let claude_servers = acp_mcp_servers(&list, "claude-acp", &init_http);
assert_eq!(claude_servers.len(), 2);
assert_eq!(claude_servers[0]["name"], "shared");
assert_eq!(claude_servers[0]["args"], json!(["-y", "workspace-pkg"]));
assert_eq!(claude_servers[1]["name"], "claude-http");
assert_eq!(claude_servers[1]["type"], "http");
let codex_servers = acp_mcp_servers(&list, "codex", &init_http);
assert_eq!(codex_servers.len(), 1);
assert_eq!(codex_servers[0]["name"], "shared");
// Disable workspace `shared` and remove user `claude-http`.
apply_set_enabled(&mut user, &mut workspace, "workspace::shared", false).unwrap();
apply_remove(&mut user, &mut workspace, "user::claude-http").unwrap();
let list = servers_from_settings(&user, &workspace);
let after = acp_mcp_servers(&list, "claude", &init_http);
assert_eq!(after.len(), 1);
assert_eq!(after[0]["args"], json!(["-y", "user-pkg"]));
}
#[test]
fn imports_from_mcp_json_and_vscode_mcp_json_without_modifying_them() {
let tmp = tempfile::tempdir().unwrap();
let mcp_json = tmp.path().join(".mcp.json");
let vscode_dir = tmp.path().join(".vscode");
std::fs::create_dir_all(&vscode_dir).unwrap();
let vscode_mcp = vscode_dir.join("mcp.json");
let raw_mcp = r#"{"mcpServers":{"sqlite":{"command":"uvx","args":["mcp-server-sqlite"]}}}"#;
let raw_vscode = r#"{"servers":{"remote":{"type":"http","url":"https://mcp.example.com"}}}"#;
std::fs::write(&mcp_json, raw_mcp).unwrap();
std::fs::write(&vscode_mcp, raw_vscode).unwrap();
let mut workspace = BTreeMap::new();
let added = apply_import(&mut workspace, tmp.path());
assert_eq!(added, 2);
assert_eq!(workspace["sqlite"].command.as_deref(), Some("uvx"));
assert_eq!(workspace["remote"].transport, McpTransport::Http);
assert_eq!(workspace["remote"].url.as_deref(), Some("https://mcp.example.com"));
// Re-importing does not duplicate or overwrite existing entries, and leaves files untouched.
workspace.get_mut("sqlite").unwrap().enabled = false;
assert_eq!(apply_import(&mut workspace, tmp.path()), 0);
assert!(!workspace["sqlite"].enabled);
assert_eq!(std::fs::read_to_string(&mcp_json).unwrap(), raw_mcp);
assert_eq!(std::fs::read_to_string(&vscode_mcp).unwrap(), raw_vscode);
}
}
-92
View File
@@ -156,14 +156,6 @@ pub struct AgentConfigOption {
pub via: AgentConfigVia,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum AgentCommandSource {
#[default]
Command,
Skill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentCommand {
@@ -172,8 +164,6 @@ pub struct AgentCommand {
pub description: String,
/// What to type after the command, when it takes input.
pub hint: Option<String>,
#[serde(default)]
pub source: AgentCommandSource,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -292,9 +282,6 @@ pub struct AgentPermissionOption {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentQuestion {
/// Stable answer identity when the provider supplies question ids.
#[serde(default)]
pub answer_key: Option<String>,
#[serde(default)]
pub header: String,
pub question: String,
@@ -419,9 +406,6 @@ pub struct AgentProvider {
pub env: BTreeMap<String, String>,
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
pub config: Vec<AgentConfigOption>,
/// Thinking-level options by model value; absent models have not reported their levels.
#[serde(default)]
pub thought_levels: BTreeMap<String, Vec<AgentConfigOption>>,
/// Slash commands it offered last time.
pub commands: Vec<AgentCommand>,
pub source: AgentSource,
@@ -518,15 +502,6 @@ pub enum AgentEvent {
index: u32,
entry: AgentEntry,
},
/// Text was appended to entry `index` (an agent message or a thought): it read the first
/// `from` UTF-16 units of its text when the previous event for it was sent.
Append {
task: String,
subagent: Option<String>,
index: u32,
from: u32,
text: String,
},
/// An agent reported the usage limits of its account (shared by every task of `provider`).
Limits {
provider: String,
@@ -535,70 +510,3 @@ pub enum AgentEvent {
/// The installed or configured agent providers changed.
Providers,
}
pub use ide_core::settings::{AgentMcpServer, McpTransport};
/// Where an MCP server configuration is stored (`settings.toml` vs `.openidea/settings.toml`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum McpScope {
User,
Workspace,
}
/// A named environment variable or HTTP header for an MCP server.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpKeyValue {
pub name: String,
pub value: String,
}
/// One configured Model Context Protocol (MCP) server.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServer {
pub id: String,
pub name: String,
pub scope: McpScope,
pub transport: McpTransport,
pub enabled: bool,
pub command: Option<String>,
pub args: Vec<String>,
pub env: Vec<McpKeyValue>,
pub url: Option<String>,
pub headers: Vec<McpKeyValue>,
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
#[serde(default)]
pub agents: Vec<String>,
}
/// Payload for creating or updating a user or workspace MCP server.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct McpServerInput {
#[serde(default)]
pub id: Option<String>,
pub name: String,
pub scope: McpScope,
pub transport: McpTransport,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<McpKeyValue>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub headers: Vec<McpKeyValue>,
/// Agent provider ids this server is enabled for; empty means enabled for all agents.
#[serde(default)]
pub agents: Vec<String>,
}
fn default_true() -> bool {
true
}
+4 -134
View File
@@ -69,75 +69,6 @@ pub(crate) fn connect(
Ok(super::serve(shim, client))
}
/// What discovery found: ACP `availableCommands`, `configOptions` for the configured model,
/// and each model's effort option by model value (`None`: none).
pub(crate) struct Discovered {
pub commands: Vec<Value>,
pub options: Value,
pub efforts: Vec<(String, Option<Value>)>,
}
/// Discover the slash commands and the model, mode and effort options without a prompt,
/// ACP session, or pending session file.
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<Discovered, String> {
tokio::time::timeout(Duration::from_secs(5), async move {
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
let mut args = spec.args;
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
let _stop = io.kill.clone().drop_guard();
let mut lines = BufReader::new(io.stdout).lines();
let init = control(
&mut io.stdin,
&mut lines,
"commands",
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
)
.await?
.ok_or("Claude Code did not initialize")?;
// The effort and model the user configured; without them the options show the defaults.
let applied = tokio::time::timeout(
Duration::from_secs(2),
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
)
.await
.ok()
.and_then(Result::ok)
.flatten()
.unwrap_or_default();
let settings = Settings::from_cli(&init, &applied);
Ok(Discovered {
commands: settings.commands.clone(),
options: settings.options(),
efforts: settings.efforts_by_model(),
})
})
.await
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
}
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
async fn control(
stdin: &mut (impl AsyncWriteExt + Unpin),
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
id: &str,
request: Value,
) -> Result<Option<Value>, String> {
let line = json!({ "type": "control_request", "request_id": id, "request": request });
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
stdin.flush().await.map_err(|e| e.to_string())?;
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
let response = &msg["response"];
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
}
}
Err("Claude Code exited".into())
}
struct Config {
pub program: String,
/// Extra arguments (a custom agent's), before the session's own.
@@ -167,7 +98,6 @@ pub(super) struct Shim {
struct Session {
id: String,
cwd: PathBuf,
mcp_config: Option<tempfile::TempPath>,
shim: Weak<Shim>,
st: Mutex<State>,
/// Serializes (re)starting the process.
@@ -334,24 +264,9 @@ impl Shim {
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
}
fn add_session(
self: &Arc<Self>,
id: &str,
cwd: PathBuf,
start: Start,
pending: bool,
mcp_config: Option<String>,
) -> Arc<Session> {
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
Ok(path) => Some(path),
Err(e) => {
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
None
}
});
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
let session = Arc::new(Session {
id: id.to_owned(),
mcp_config,
shim: Arc::downgrade(self),
st: Mutex::new(State {
proc: None,
@@ -397,7 +312,6 @@ impl Shim {
"loadSession": true,
"promptCapabilities": { "image": true, "embeddedContext": true },
"sessionCapabilities": { "fork": {} },
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
},
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
@@ -406,13 +320,7 @@ impl Shim {
}
"session/new" => {
let id = new_uuid(false);
let session = self.add_session(
&id,
self.cwd_of(&params),
Start::New { id: id.clone() },
false,
mcp_config(&params),
);
let session = self.add_session(&id, self.cwd_of(&params), Start::New { id: id.clone() }, false);
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
session.announce_commands();
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
@@ -428,7 +336,7 @@ impl Shim {
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
};
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
let session = self.add_session(id, cwd, start, pending, mcp_config(&params));
let session = self.add_session(id, cwd, start, pending);
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
if method == "session/load" {
session.replay(&chain);
@@ -454,7 +362,7 @@ impl Shim {
(start, st.settings.clone())
};
self.save_pending(&id, Some(&start));
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(&params));
let session = self.add_session(&id, source.cwd.clone(), start, true);
session.st.lock().settings = settings;
let options = session.st.lock().settings.options();
Ok(json!({ "sessionId": id, "configOptions": options }))
@@ -648,9 +556,6 @@ impl Session {
old.end();
}
let mut args = shim.config.args.clone();
if let Some(config) = &self.mcp_config {
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
}
args.extend(start.args());
let launch =
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
@@ -1440,38 +1345,3 @@ fn start_from_json(v: &Value) -> Option<Start> {
}
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
}
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
std::io::Write::write_all(&mut file, json.as_bytes())?;
Ok(file.into_temp_path())
}
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
fn mcp_config(params: &Value) -> Option<String> {
let servers = params.get("mcpServers")?.as_array()?;
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
v.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
.collect()
};
let mut out = serde_json::Map::new();
for server in servers {
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
let entry = match server.get("type").and_then(Value::as_str) {
Some(kind @ ("http" | "sse")) => {
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
}
_ => json!({
"command": server.get("command"),
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
"env": pairs(server.get("env")),
}),
};
out.insert(name.to_owned(), entry);
}
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
}
+9 -38
View File
@@ -132,18 +132,19 @@ impl Settings {
}).collect::<Vec<_>>(),
}));
}
if let Some(effort) = self.current_model().and_then(|m| effort_option(m, &self.effort)) {
out.push(effort);
let efforts = self.current_model().map(|m| m.efforts.as_slice()).unwrap_or_default();
if !efforts.is_empty() {
let mut values = vec![json!({ "value": "default", "name": "Default" })];
values.extend(efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
let current = if efforts.contains(&self.effort) { self.effort.as_str() } else { "default" };
out.push(json!({
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
"currentValue": current, "options": values,
}));
}
Value::Array(out)
}
/// Each model's effort option by model value (`None`: it has no effort levels): what a new
/// task offers once that model is picked, before any session has run it.
pub fn efforts_by_model(&self) -> Vec<(String, Option<Value>)> {
self.models.iter().map(|m| (m.value.clone(), effort_option(m, &self.effort))).collect()
}
pub fn valid(&self, option: &str, value: &str) -> bool {
self.options()
.as_array()
@@ -155,20 +156,6 @@ impl Settings {
}
}
/// `model`'s effort option, with `effort` selected when the model offers it; `None` without efforts.
fn effort_option(model: &Model, effort: &str) -> Option<Value> {
if model.efforts.is_empty() {
return None;
}
let mut values = vec![json!({ "value": "default", "name": "Default" })];
values.extend(model.efforts.iter().map(|e| json!({ "value": e, "name": capitalize(e) })));
let current = if model.efforts.iter().any(|e| e == effort) { effort } else { "default" };
Some(json!({
"id": EFFORT, "name": "Effort", "category": "thought_level", "type": "select",
"currentValue": current, "options": values,
}))
}
/// Slash commands as ACP `availableCommands`, without those that need Claude Code's terminal.
pub(super) fn commands(list: Option<&Value>, terminal_only: &[String]) -> Vec<Value> {
list.and_then(Value::as_array)
@@ -244,20 +231,4 @@ mod tests {
let cmds = commands(init().get("commands"), &["doctor".into()]);
assert_eq!(cmds, vec![json!({"name": "review", "description": "Review", "input": {"hint": "[pr]"}})]);
}
#[test]
fn every_model_offers_its_own_efforts() {
let s = Settings::from_cli(&init(), &json!({"applied": {"model": "claude-sonnet-5-5", "effort": "high"}}));
let efforts = s.efforts_by_model();
let values = |model: &str| {
let (_, o) = efforts.iter().find(|(m, _)| m == model).unwrap();
let o = o.as_ref().unwrap();
let values: Vec<_> = o["options"].as_array().unwrap().iter().map(|v| v["value"].clone()).collect();
(values, o["currentValue"].clone())
};
assert_eq!(values("default"), (vec![json!("default"), json!("low"), json!("high")], json!("high")));
// The configured effort only where the model offers it.
assert_eq!(values("opus"), (vec![json!("default"), json!("low"), json!("max")], json!("default")));
assert!(efforts.contains(&("claude-haiku-4-5".into(), None)), "no effort option without levels");
}
}
@@ -1170,29 +1170,3 @@ async fn skipped_questions_have_no_answers() {
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
assert_eq!(cancelled, None);
}
#[test]
fn mcp_servers_become_an_mcp_config() {
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
assert_eq!(mcp_config(&json!({})), None);
let config = mcp_config(&json!({ "mcpServers": [
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
] }))
.unwrap();
let v: Value = serde_json::from_str(&config).unwrap();
assert_eq!(
v["mcpServers"]["openidea"],
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
);
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
let path = write_mcp_config(&config).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
let kept = path.to_path_buf();
drop(path);
assert!(!kept.exists());
}
+31 -120
View File
@@ -8,7 +8,6 @@ mod translate;
mod tests;
use super::Shim;
use crate::acp::AUTH_REQUIRED;
use crate::provider::ProviderSpec;
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
use parking_lot::Mutex;
@@ -28,6 +27,8 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
const EARLY_CANCEL: Duration = Duration::from_secs(1);
/// How long the client's connection outlives codex.
const EXIT_GRACE: Duration = Duration::from_millis(100);
/// ACP's error code for a request that needs the user to sign in.
const AUTH_REQUIRED: i64 = -32000;
pub(crate) fn connect(
spec: &ProviderSpec,
@@ -53,7 +54,6 @@ fn attach(
client: OnceLock::new(),
state: Mutex::new(State::default()),
models: tokio::sync::Mutex::new(None),
skills: tokio::sync::Mutex::new(()),
});
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
let _ = shim.codex.set(codex.clone());
@@ -83,8 +83,6 @@ struct CodexShim {
state: Mutex<State>,
/// `model/list`, fetched once.
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
/// Keep skill snapshots in request order, including invalidations during session setup.
skills: tokio::sync::Mutex<()>,
}
#[derive(Default)]
@@ -206,7 +204,7 @@ fn from_codex(e: RpcError) -> ResponseError {
code,
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
);
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
ResponseError { code, message, data }
}
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
@@ -266,7 +264,7 @@ impl CodexShim {
"agentCapabilities": {
"loadSession": true,
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
"mcpCapabilities": { "http": false, "sse": false },
"sessionCapabilities": { "fork": {}, "close": {} },
"_meta": { "openidea": { "rollback": true, "steer": true } },
},
@@ -303,20 +301,6 @@ impl CodexShim {
models
}
async fn publish_skills(&self, session: &str) {
let _listing = self.skills.lock().await;
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
let Some(cwd) = cwd else { return };
let result =
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
if let Ok(Ok(result)) = result
&& let Some(update) = translate::skill_commands(&result)
&& self.state.lock().threads.contains_key(session)
{
self.emit(session, update);
}
}
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
let thread = result.get("thread").unwrap_or(&Value::Null);
@@ -334,16 +318,11 @@ impl CodexShim {
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
let models = self.models().await;
let mut request = json!({ "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/start", request).await?;
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
let (id, options) = self.open_thread(&result, &models, None);
if id.is_empty() {
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
}
self.publish_skills(&id).await;
Ok(json!({ "sessionId": id, "configOptions": options }))
}
@@ -351,18 +330,13 @@ impl CodexShim {
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
let id = str_param(params, "sessionId")?;
let models = self.models().await;
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
let result = self.call("thread/resume", request).await?;
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
let (_, options) = self.open_thread(&result, &models, None);
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
let thread = result.get("thread").unwrap_or(&Value::Null);
for update in translate::history(thread, cwd.as_deref()) {
self.emit(id, update);
}
self.publish_skills(id).await;
Ok(json!({ "configOptions": options }))
}
@@ -371,9 +345,6 @@ impl CodexShim {
let models = self.models().await;
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
if let Some(config) = translate::mcp_config(params) {
request["config"] = config;
}
if let Some(s) = &settings {
request["model"] = json!(s.model_slug(&models));
request["approvalPolicy"] = json!(s.mode.approval_policy());
@@ -381,7 +352,6 @@ impl CodexShim {
}
let result = self.call("thread/fork", request).await?;
let (id, options) = self.open_thread(&result, &models, settings);
self.publish_skills(&id).await;
Ok(json!({ "sessionId": id, "configOptions": options }))
}
@@ -571,25 +541,9 @@ impl CodexShim {
}
}
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
fn on_codex_notification(&self, method: &str, params: &Value) {
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
match method {
"skills/changed" => {
let shim = self.clone();
let sessions: Vec<String> = self
.state
.lock()
.threads
.iter()
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
.map(|(id, _)| id.clone())
.collect();
self.runtime.spawn(async move {
for session in sessions {
shim.publish_skills(&session).await;
}
});
}
"turn/started" => {
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
self.turn_known(thread, turn, true);
@@ -868,7 +822,7 @@ impl CodexShim {
match method {
"item/commandExecution/requestApproval" => {
let choices = translate::decisions(params, true);
let title = translate::command_approval_title(params, item.as_ref());
let title = translate::command_approval_title(params, item.as_ref(), cwd);
let call = tool_call_of(
&item_id,
&title,
@@ -917,55 +871,31 @@ impl CodexShim {
Ok(self.ask(&thread, call, &choices, json!({ "permissions": {}, "scope": "turn" })))
}
"item/tool/requestUserInput" => {
let questions = params.get("questions").and_then(Value::as_array).cloned().unwrap_or_default();
// One permission request carrying all the questions, so the client shows
// them as a card (as it does for Claude's `AskUserQuestion`).
let asked: Vec<Value> = questions
.iter()
.filter_map(|q| {
let id = q.get("id").and_then(Value::as_str)?;
let question = q.get("question").and_then(Value::as_str)?;
let options: Vec<Value> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| {
let label = o.get("label").and_then(Value::as_str)?;
Some(json!({
"label": label,
"description": o.get("description").and_then(Value::as_str).unwrap_or_default(),
}))
})
.collect();
Some(json!({
"answerKey": id,
"header": q.get("header").and_then(Value::as_str).unwrap_or_default(),
"question": question,
"multiSelect": q.get("multiSelect").and_then(Value::as_bool).unwrap_or(false),
"options": options,
}))
})
.collect();
if asked.is_empty() {
return Ok(json!({ "answers": {} }));
}
let title = asked[0].get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let mut call = tool_call_of(&item_id, title, "other", None, None);
call["_meta"] = json!({ "questions": asked });
let options = [json!({ "optionId": "submit", "name": "Submit", "kind": "allow_once" })];
let picked: serde_json::Map<String, Value> = self
.ask_option(&thread, call, &options)
.and_then(|reply| serde_json::from_str(&reply).ok())
.unwrap_or_default();
let mut answers = serde_json::Map::new();
for q in &questions {
for q in params.get("questions").and_then(Value::as_array).into_iter().flatten() {
let Some(qid) = q.get("id").and_then(Value::as_str) else { continue };
if let Some(labels) = picked
.get(qid)
.filter(|labels| labels.as_array().is_some_and(|labels| labels.iter().all(Value::is_string)))
{
answers.insert(qid.to_owned(), json!({ "answers": labels }));
let choices: Vec<Choice> = q
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.filter_map(|(i, o)| {
let label = o.get("label")?.as_str()?;
Some(Choice {
option: json!({ "optionId": i.to_string(), "name": label, "kind": "allow_once" }),
answer: json!({ "answers": [label] }),
})
})
.collect();
if choices.is_empty() {
continue;
}
let title = q.get("question").and_then(Value::as_str).unwrap_or("Codex has a question");
let call = tool_call_of(&item_id, title, "other", None, None);
let answer = self.ask(&thread, call, &choices, Value::Null);
if !answer.is_null() {
answers.insert(qid.to_owned(), answer);
}
}
Ok(json!({ "answers": answers }))
@@ -998,25 +928,6 @@ impl CodexShim {
}
}
/// Asks the client; returns the selected option id (`None` when cancelled).
fn ask_option(&self, session: &str, tool_call: Value, options: &[Value]) -> Option<String> {
let client = self.client.get().cloned()?;
let params = json!({ "sessionId": session, "toolCall": tool_call, "options": options });
let outcome: Result<Value, RpcError> =
self.runtime.block_on(client.request("session/request_permission", params, None));
match outcome {
Ok(outcome) => outcome
.pointer("/outcome/outcome")
.and_then(Value::as_str)
.filter(|outcome| *outcome == "selected")
.and_then(|_| outcome.pointer("/outcome/optionId").and_then(Value::as_str).map(str::to_owned)),
Err(e) => {
tracing::debug!("codex: question request failed: {e}");
None
}
}
}
/// Asks the client; returns the answer for codex (`otherwise` when cancelled).
fn ask(&self, session: &str, tool_call: Value, choices: &[Choice], otherwise: Value) -> Value {
let Some(client) = self.client.get().cloned() else { return otherwise };
+8 -219
View File
@@ -247,39 +247,13 @@ fn approvals_offer_what_codex_offers_and_always_a_decline() {
assert_eq!(
command_approval_title(
&json!({"command": "/bin/zsh -lc 'ls'", "commandActions": [{"type": "unknown", "command": "touch b"}]}),
None,
None
),
"Run ls"
"Run touch b"
);
}
#[test]
fn approval_title_shows_the_script_inside_a_shell_wrapper() {
let title = |command: &str| command_approval_title(&json!({ "command": command }), None);
assert_eq!(
title(r#"/bin/zsh -lc "python3 -c 'import pathlib\np = pathlib.Path(\"a.txt\")'""#),
"Run python3 -c 'import pathlib\\np = pathlib.Path(\"a.txt\")'"
);
assert_eq!(title(r#"bash -lc 'echo '\''hi'\'''"#), "Run echo 'hi'");
assert_eq!(title("/bin/zsh -lc ls"), "Run /bin/zsh -lc ls");
assert_eq!(title("/bin/zsh -lc 'ls' && rm x"), "Run /bin/zsh -lc 'ls' && rm x");
assert_eq!(title("python3 -c 'print(1)'"), "Run python3 -c 'print(1)'");
}
#[test]
fn approval_command_uses_its_request_and_matching_item_only() {
let command = "python3 -c 'import pathlib\np = pathlib.Path(\"note.txt\")\np.write_text(\"hi\\n\")'";
let pending_read = json!({"id": "read", "type": "commandExecution", "command": "ls",
"commandActions": [{"type": "listFiles", "path": "."}]});
let request = json!({"itemId": "write", "command": command});
assert_eq!(command_approval_title(&request, Some(&pending_read)), format!("Run {command}"));
let item = json!({"id": "write", "command": command});
let request = json!({"itemId": "write"});
assert_eq!(command_approval_title(&request, Some(&item)), format!("Run {command}"));
assert_eq!(command_approval_title(&request, Some(&pending_read)), "Run a command");
assert_eq!(command_approval_title(&request, None), "Run a command");
}
#[test]
fn history_replays_turns() {
let thread = json!({"turns": [{"id": "t", "status": "completed", "items": [
@@ -342,39 +316,6 @@ impl Client {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn questions_share_one_card_and_keep_their_ids() {
let client = Arc::new(Client::default());
client.answers.lock().extend([Some(r#"{"first":["One"],"second":["Two","Custom"]}"#), None, Some("invalid")]);
let (ours, theirs) = tokio::io::duplex(1 << 16);
let (read, write) = tokio::io::split(theirs);
let codex = Peer::connect_framed(read, write, Arc::new(ide_jsonrpc::NoopHandler), Framing::Lines);
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let params = json!({"threadId": "s", "itemId": "q", "questions": [
{"id": "first", "question": "Which?", "options": [{"label": "One", "description": "First"}]},
{"id": "second", "question": "Which?", "multiSelect": true, "options": [{"label": "Two"}]}
]});
for expected in
[json!({"first": {"answers": ["One"]}, "second": {"answers": ["Two", "Custom"]}}), json!({}), json!({})]
{
let reply: Value = codex.request("item/tool/requestUserInput", params.clone(), None).await.unwrap();
assert_eq!(reply, json!({"answers": expected}));
}
let asked = client.asked.lock();
assert_eq!(asked.len(), 3);
let questions = &asked[0]["toolCall"]["_meta"]["questions"];
assert_eq!(questions.as_array().unwrap().len(), 2);
assert_eq!(questions[0]["answerKey"], "first");
assert_eq!(questions[1]["answerKey"], "second");
assert_eq!(questions[0]["options"][0]["description"], "First");
acp.kill();
codex.kill();
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Scenario {
AuthFailure,
@@ -528,21 +469,12 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
let mut sent = Vec::new();
for (out, frame) in &t.frames {
if *out {
let actual: Value = loop {
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
// Older recordings predate skill discovery; play an unsupported server.
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
continue;
}
break actual;
};
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
.unwrap()
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
let actual: Value = serde_json::from_str(&line).unwrap();
match frame.get("method") {
Some(method) => {
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
@@ -632,23 +564,6 @@ async fn auth_failure_names_auth() {
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
}
#[test]
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
for (code, message, expected) in [
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
] {
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
assert_eq!(response.code, expected, "{message}");
assert_eq!(response.message, message);
assert_eq!(response.data, Some(json!({"detail": 1})));
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
@@ -1053,129 +968,3 @@ fn v_method(line: &str) -> Option<&str> {
let at = line.find("\"method\":\"")? + 10;
line[at..].split('"').next()
}
#[test]
fn session_mcp_servers_become_thread_configuration() {
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
assert_eq!(
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
Some(
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
for (method, native) in
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
{
for initial in [
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
]}]}}),
json!({"error": {"code": -32601, "message": "unsupported"}}),
json!({"result": {}}),
] {
let listed = initial.pointer("/result/data").is_some();
let mut initial = initial;
initial["id"] = json!(4);
let transcript = Transcript {
meta: Value::Null,
frames: vec![
(true, json!({"id": 1, "method": "initialize"})),
(false, json!({"id": 1, "result": {}})),
(true, json!({"method": "initialized"})),
(true, json!({"id": 2, "method": "model/list"})),
(false, json!({"id": 2, "result": {"data": []}})),
(true, json!({"id": 3, "method": native})),
(
false,
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
),
(true, json!({"id": 4, "method": "skills/list"})),
(false, initial),
(true, json!({"id": 5, "method": "turn/start"})),
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 6, "method": "skills/list"})),
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 7, "method": "skills/list"})),
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
(false, json!({"method": "skills/changed", "params": {}})),
(true, json!({"id": 8, "method": "skills/list"})),
(false, json!({"id": 8, "result": {"data": []}})),
],
};
let (ours, theirs) = tokio::io::duplex(1 << 16);
let client = Arc::new(Client::default());
let acp = attach(client.clone(), |events| {
let (read, write) = tokio::io::split(ours);
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
})
.unwrap();
let (read, write) = tokio::io::split(theirs);
let player = tokio::spawn(async move { play(&transcript, read, write).await });
request(&acp, "initialize", json!({})).await.unwrap();
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
let initial_updates = client.of("s");
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
if listed {
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
}
assert!(
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
.await
.is_err()
);
let (frames, _read, _write) = player.await.unwrap();
for frame in sent(&frames, "skills/list") {
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
}
assert_eq!(
sent(&frames, "turn/start")[0]["params"]["input"],
json!([{"type": "text", "text": "/$review check this"}])
);
tokio::time::timeout(Duration::from_secs(5), async {
while client.of("s").len() < usize::from(listed) + 2 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let updates = client.of("s");
assert_eq!(updates.len(), usize::from(listed) + 2);
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
acp.kill();
}
}
}
#[test]
fn skills_become_commands_with_their_invocation_names() {
let update = skill_commands(&json!({"data": [{"skills": [
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
{"name": "review", "enabled": true, "description": "Duplicate"},
{"name": "hidden", "enabled": false, "description": "Disabled"},
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
{"name": "full", "enabled": true, "description": "Full description"},
{"name": "", "enabled": true}, {"description": "No name"}
]}]}))
.unwrap();
assert_eq!(
update,
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
{"name": "$review", "description": "Short", "source": "skill", "input": null},
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
]})
);
let commands = crate::config::commands_of(&update, "codex");
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
assert_eq!(commands[0].name, "$review");
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
assert!(skill_commands(&json!({})).is_none());
}
+9 -63
View File
@@ -256,31 +256,6 @@ fn effort_name(effort: &str) -> String {
}
}
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
let entries = result.get("data")?.as_array()?;
let mut seen = std::collections::HashSet::new();
let commands: Vec<Value> = entries
.iter()
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
.flatten()
.filter_map(|skill| {
let name = skill.get("name")?.as_str()?.trim();
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
return None;
}
let description = skill
.pointer("/interface/shortDescription")
.and_then(Value::as_str)
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
.or_else(|| skill.get("description").and_then(Value::as_str))
.unwrap_or(name);
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
})
.collect();
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
}
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
@@ -758,19 +733,18 @@ pub(super) fn answer_of(choices: &[Choice], outcome: &Value, decline: Value) ->
}
/// The ACP title of a command approval.
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>) -> String {
pub(super) fn command_approval_title(params: &Value, item: Option<&Value>, cwd: Option<&str>) -> String {
if let Some(host) = params.pointer("/networkApprovalContext/host").and_then(Value::as_str) {
return format!("Allow network access to {host}");
}
// Approval payloads can describe a different command from the item's earlier summary.
let item = item.filter(|i| i.get("id") == params.get("itemId"));
let command = params
.get("command")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.or_else(|| item.and_then(|i| i.get("command")).and_then(Value::as_str).filter(|c| !c.is_empty()));
match command {
Some(command) => format!("Run {}", crate::shell_script(command).unwrap_or_else(|| command.to_owned())),
let from_item = item.and_then(|i| tool_call(i, false, cwd, None)).and_then(|c| str_of(&c, "title"));
let title = from_item.or_else(|| {
let synthetic = json!({ "id": "", "type": "commandExecution", "command": params.get("command"),
"commandActions": params.get("commandActions").cloned().unwrap_or(json!([])) });
tool_call(&synthetic, false, cwd, None).and_then(|c| str_of(&c, "title"))
});
match title.filter(|t| !t.is_empty()) {
Some(t) => format!("Run {t}"),
None => "Run a command".to_owned(),
}
}
@@ -861,31 +835,3 @@ pub(super) fn tail(text: &str) -> String {
fn str_of(v: &Value, key: &str) -> Option<String> {
v.get(key).and_then(Value::as_str).map(str::to_owned)
}
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
let mut servers = serde_json::Map::new();
for server in params.get("mcpServers")?.as_array()? {
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
continue;
}
let (Some(name), Some(command)) =
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
else {
continue;
};
let env: serde_json::Map<String, Value> = server
.get("env")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
.collect();
servers.insert(
name.into(),
json!({"command":command,
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
);
}
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
}
+1 -213
View File
@@ -5,7 +5,6 @@
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -64,7 +63,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> {
@@ -83,91 +81,6 @@ fn builtins() -> Vec<ProviderSpec> {
.collect()
}
impl ProviderSpec {
pub(crate) fn login_hint(&self, program: &Path) -> String {
if !program.is_absolute() {
return self.login_hint.clone();
}
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
let builtin = builtin_spec(&self.id);
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
}
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
let name = &self.name;
let program = Path::new(&self.command);
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
.then(|| {
auth_methods.iter().find_map(|method| {
if method.get("type").and_then(Value::as_str) != Some("terminal") {
return None;
}
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
})
})
.flatten();
let hint = match terminal_args {
Some(args) => {
let command = render_login_command(program, args, cfg!(windows));
format!("Run `{command}` in a terminal.")
}
None => self.login_hint(program),
};
format!("{name} needs you to sign in. {hint}")
}
/// Resolve once before connecting, so authentication guidance names that same install.
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
if self.driver != Driver::Acp {
self.command = crate::native::newest_install(&self.command);
}
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
self.command =
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
.to_string_lossy()
.into_owned();
self
}
}
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
let prefix = format!("Run `{}", builtin.command);
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
return spec.login_hint.clone();
};
let same_path = |a: &Path, b: &Path| {
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
};
if on_path.is_some_and(|path| same_path(path, program)) {
return spec.login_hint.clone();
}
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
let command = render_login_command(program, args.split_whitespace(), windows);
match suffix {
" in a terminal." => format!("Run `{command}` in a terminal."),
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
_ => spec.login_hint.clone(),
}
}
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
let quote = |word: &str| {
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
};
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
for arg in args {
command.push(' ');
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
command.push_str(arg);
} else {
command.push_str(&quote(arg));
}
}
command
}
/// Whether `id` names a built-in agent that needs no registry install (native drivers
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
@@ -185,14 +98,6 @@ pub(crate) fn builtin_spec(id: &str) -> Option<ProviderSpec> {
builtins().into_iter().find(|s| s.id == id)
}
/// The ACP adapters of Claude Code and Codex, which the native drivers replace: they still
/// launch, but are listed only once customized through an agent file or used by a task.
const UNLISTED: &[&str] = &["claude-acp", "codex-acp"];
pub(crate) fn is_unlisted(spec: &ProviderSpec) -> bool {
UNLISTED.contains(&spec.id.as_str()) && builtin_spec(&spec.id).as_ref() == Some(spec)
}
/// Public launch metadata, keeping the built-in defaults separate from agent-file overrides.
pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
let builtin = builtin_spec(&spec.id);
@@ -214,14 +119,13 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
args: spec.args.clone(),
default_program: builtin.as_ref().map(|b| b.command.clone()),
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
login_hint: Some(spec.login_hint.clone()),
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
command_editable: reason.is_none(),
command_read_only_reason: reason,
installed: installed(&spec.command),
env: spec.env.iter().cloned().collect(),
config: vec![],
thought_levels: BTreeMap::new(),
commands: vec![],
source: spec.source.clone(),
}
@@ -407,113 +311,6 @@ fn is_executable(p: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
for spec in builtins() {
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
for windows in [false, true] {
let program =
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
let hint = render_login_hint(&spec, program, None, windows);
if separate {
assert_eq!(hint, spec.login_hint, "{}", spec.id);
} else {
let prefix =
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
assert_eq!(
render_login_hint(&spec, program, Some(program), windows),
spec.login_hint,
"a PATH-resolved command stays bare: {}",
spec.id
);
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
}
}
}
}
#[test]
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
let cursor = builtin_spec("cursor").unwrap();
assert_eq!(
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
);
assert_eq!(
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
);
let opencode = builtin_spec("opencode").unwrap();
assert_eq!(
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
"Run `'/registry/opencode' auth login` in a terminal."
);
let mut custom = cursor;
custom.id = "custom".into();
custom.login_hint = "Sign in with the agent's own command line first.".into();
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
}
#[test]
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
let dir = tempfile::tempdir().unwrap();
let mut cursor = builtin_spec("cursor").unwrap();
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
let program = dir.path().join(command);
std::fs::write(&program, "").unwrap();
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
let resolved = cursor.clone().resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), program);
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
cursor.command = format!("tools/{command}");
let resolved = cursor.resolve_for_launch(dir.path());
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
}
#[test]
fn terminal_login_commands_quote_the_binary_and_each_argument() {
for (windows, expected) in [
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
] {
assert_eq!(
render_login_command(
Path::new("/agent's tools/$cli"),
["auth", "login", "--account", "user name", "a'b", "$token", ""],
windows
),
expected
);
}
let mut spec = builtin_spec("cursor").unwrap();
spec.id = "custom".into();
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
spec.login_hint = "Sign in with the agent's own command line first.".into();
for method in [
json!({"type": "terminal"}),
json!({"type": "terminal", "args": ["login", null]}),
json!({"type": "agent", "args": ["login"]}),
] {
assert_eq!(
spec.sign_in_message(&[method]),
"Cursor needs you to sign in. Sign in with the agent's own command line first."
);
}
}
#[cfg(unix)]
#[test]
fn registry_login_hint_names_the_installed_executable() {
let mut spec = builtin_spec("cursor").unwrap();
spec.command = "/registry path/dist-package/cursor-agent".into();
assert_eq!(
describe(&spec).login_hint.as_deref(),
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
);
}
#[test]
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
@@ -599,15 +396,6 @@ mod tests {
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
}
#[test]
fn acp_adapters_of_native_agents_are_unlisted_until_customized() {
let dir = tempfile::tempdir().unwrap();
let unlisted = |dir: &Path| load(Some(dir)).into_iter().filter(is_unlisted).map(|s| s.id).collect::<Vec<_>>();
assert_eq!(unlisted(dir.path()), ["claude-acp", "codex-acp"]);
set_builtin_env(dir.path(), "codex-acp", BTreeMap::from([("A".to_owned(), "1".to_owned())])).unwrap();
assert_eq!(unlisted(dir.path()), ["claude-acp"]);
}
#[test]
fn set_builtin_command_overrides_and_clears_a_builtin() {
let dir = tempfile::tempdir().unwrap();
+9 -32
View File
@@ -2,7 +2,7 @@
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
//! of the working directory and every directory above it, then the user's `~/.claude`.
use crate::{AgentCommand, AgentCommandSource};
use crate::AgentCommand;
use std::collections::HashSet;
use std::path::Path;
@@ -36,14 +36,9 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
/// `first` (the agent's own list) plus the commands of `more` it lacks.
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
let mut out = first;
for c in more {
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
offered.source = c.source;
} else {
out.push(c);
}
}
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
out
}
@@ -66,7 +61,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
return None;
}
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
Some(meta.command(name, &text, AgentCommandSource::Skill))
Some(meta.command(name, &text))
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -84,7 +79,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
} else if let Some(stem) = name.strip_suffix(".md")
&& let Ok(text) = std::fs::read_to_string(&path)
{
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
}
}
}
@@ -152,7 +147,7 @@ impl Frontmatter {
}
/// `description`, else the body's first line.
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
fn command(&self, name: String, text: &str) -> AgentCommand {
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
let body = text.get(self.body..).unwrap_or_default();
body.lines()
@@ -163,7 +158,6 @@ impl Frontmatter {
});
AgentCommand {
name,
source,
description: truncate(&description),
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
}
@@ -200,20 +194,13 @@ mod tests {
}
fn cmd(name: &str, description: &str) -> AgentCommand {
AgentCommand {
name: name.into(),
description: description.into(),
hint: None,
source: AgentCommandSource::Command,
}
AgentCommand { name: name.into(), description: description.into(), hint: None }
}
#[test]
fn project_and_user_skills_and_commands() {
let tmp = tempfile::tempdir().unwrap();
// Home is an ancestor of the project, so the walk stops there instead of climbing
// past it into the real profile (a temp dir under the user's home holds its skills).
let home = tmp.path().to_path_buf();
let home = tmp.path().join("home");
let repo = tmp.path().join("work").join("idea");
let skills = repo.join(".claude/skills");
write(
@@ -240,12 +227,6 @@ mod tests {
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
);
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
for name in ["issue-fix", "triage", "verify", "lint"] {
assert_eq!(by(name).source, AgentCommandSource::Skill);
}
for name in ["git:commit", "review", "parent", "brooks-review"] {
assert_eq!(by(name).source, AgentCommandSource::Command);
}
assert_eq!(by("triage").description, "One pass over open issues and PRs");
assert_eq!(by("issue-fix").description, "Fix an issue");
assert_eq!(by("verify").description, "Build and drive the app");
@@ -281,14 +262,10 @@ mod tests {
fn the_agents_entries_win() {
let merged = merge(
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
vec![
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
cmd("verify", "Verify"),
],
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
);
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
assert_eq!(merged[0].description, "from the agent");
assert_eq!(merged[0].source, AgentCommandSource::Skill);
}
#[test]
File diff suppressed because it is too large. Load diff
+1 -23
View File
@@ -290,8 +290,7 @@ impl Timeline {
/// and records when the turn ended (`done`) on the message that started it.
pub fn end_turn(&mut self, done: Option<f64>) -> Vec<usize> {
let mut changed = Vec::new();
// A steered message rides along in a running turn; the one that started it owns its end.
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { steered: false, .. }));
let started = self.entries.iter().rposition(|e| matches!(e, AgentEntry::User { .. }));
for (i, e) in self.entries.iter_mut().enumerate() {
match e {
AgentEntry::User { done_ms, .. } if Some(i) == started && done.is_some() && done_ms.is_none() => {
@@ -469,27 +468,6 @@ mod tests {
assert_eq!(t.end_turn(Some(9.0)), Vec::<usize>::new());
}
#[test]
fn end_turn_records_on_the_message_that_started_the_turn() {
let user = |sent: f64, steered: bool| AgentEntry::User {
text: "q".into(),
attachments: vec![],
images: vec![],
sent_ms: Some(sent),
done_ms: None,
steered,
};
let mut t = Timeline::default();
t.push(user(1.0, false));
t.push(AgentEntry::Agent { text: "a".into() });
t.push(user(2.0, true));
// The steered message rides along in the turn: the reply's time belongs to the
// message that started it.
assert_eq!(t.end_turn(Some(5.0)), vec![0]);
assert!(matches!(t.entries[0], AgentEntry::User { done_ms: Some(5.0), .. }));
assert!(matches!(t.entries[2], AgentEntry::User { done_ms: None, .. }));
}
#[test]
fn long_output_keeps_its_tail() {
let s = tail("é".repeat(MAX_OUTPUT));
+1 -72
View File
@@ -116,7 +116,7 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
/// Size and modification time of a file, `None` once it is gone.
pub(crate) type Stamp = Option<(u64, u128)>;
pub(crate) fn stamp(path: &Path) -> Stamp {
fn stamp(path: &Path) -> Stamp {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos();
Some((meta.len(), modified))
@@ -132,77 +132,6 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
changes.into_iter().filter(|c| before.get(&c.rel_path).is_none_or(|s| *s != stamp(&c.path))).collect()
}
/// Files larger than this that were changed before the task keep no copy: they can't be rolled back.
const SAVED_MAX: u64 = 8 * 1024 * 1024;
fn saved_name(rel: &str) -> String {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hash = DefaultHasher::new();
rel.hash(&mut hash);
format!("{:016x}", hash.finish())
}
/// Copies the files in `before` (already changed in the checkout at `cwd` when the task started)
/// into `dir`, so rolling back the task's changes to them restores the user's own edits.
/// Whether anything was saved.
pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Path) -> IdeResult<bool> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(false) };
let mut saved = false;
for (rel, stamp) in before {
let path = repo.root().join(rel);
if stamp.is_none() || !std::fs::metadata(&path).is_ok_and(|m| m.is_file() && m.len() <= SAVED_MAX) {
continue;
}
std::fs::create_dir_all(dir)?;
std::fs::copy(&path, dir.join(saved_name(rel)))?;
saved = true;
}
Ok(saved)
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
) -> IdeResult<()> {
let path = &change.path.0;
let content = match before.get(&change.rel_path) {
Some(None) => None,
Some(Some(_)) => {
let copy =
saved.map(|d| d.join(saved_name(&change.rel_path))).filter(|p| p.is_file()).ok_or_else(|| {
IdeError::invalid(format!(
"{} had changes of yours when the task started that weren't saved; roll it back in Git instead",
change.rel_path
))
})?;
Some(std::fs::read(copy)?)
}
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
}
None => match std::fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
}
Ok(())
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
-2
View File
@@ -7,9 +7,7 @@ license.workspace = true
[dependencies]
blake3.workspace = true
chardetng.workspace = true
dunce.workspace = true
encoding_rs.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
+1 -79
View File
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
return Ok(None);
}
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
let plan = launch_plan(root, model, module, test)?;
let mut environment: BTreeMap<String, String> = std::env::vars()
.map(|(key, value)| (key.to_ascii_uppercase(), value))
.filter(|(key, _)| {
@@ -184,30 +184,6 @@ pub fn launch_fingerprint(
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
impl LaunchFingerprint {
/// Relative paths whose content changed while `(length, mtime)` stayed the same
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
/// re-stamp the returned paths before building. Inputs without a content hash
/// (directories, files outside the project) are never reported.
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
let mut edits = Vec::new();
for (path, current_stamp) in &current.files {
let Some(current_stamp) = current_stamp else { continue };
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
edits.push(path.clone());
}
}
edits.sort();
edits
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -306,60 +282,6 @@ mod tests {
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn mtime_preserved_edits_only_reports_lying_timestamps() {
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
}
fn prints(
previous: BTreeMap<PathBuf, Option<Stamp>>,
current: BTreeMap<PathBuf, Option<Stamp>>,
) -> Vec<PathBuf> {
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
previous.mtime_preserved_edits(&current)
}
// Same length and mtime, different content: the builder would skip it.
assert_eq!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
vec![PathBuf::from("a.java")]
);
// A normal edit bumps the mtime: the builder already sees it.
let mut touched = stamp(5, "new").unwrap();
touched.1 += std::time::Duration::from_secs(1);
assert!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
);
// Added, deleted, and hash-less inputs (directories, files outside the
// project) carry no comparable content: never reported.
assert!(
prints(
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
)
.is_empty()
);
}
#[test]
fn lying_source_is_found_against_a_real_fingerprint() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model).unwrap();
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content, preserved mtime.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model).unwrap();
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
// A normal edit (mtime advances) needs no repair.
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
let normal = fingerprint(root, &model).unwrap();
assert!(before.mtime_preserved_edits(&normal).is_empty());
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+30 -189
View File
@@ -67,11 +67,6 @@ impl BuildStep {
}
}
/// A CMake configure/build invocation, parsed as compiler output in the Build view.
pub fn cmake(program: &str, args: Vec<String>, cwd: PathBuf, env: BTreeMap<String, String>) -> Self {
Self { program: program.into(), args, cwd, env, format: Format::Gcc }
}
/// The command line as echoed in the console.
pub fn display(&self) -> String {
let program = Path::new(&self.program).file_name().map_or(self.program.clone(), |n| n.to_string_lossy().into());
@@ -158,7 +153,7 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
} else if has(dir, &["go.mod", "go.work"]) {
} else if has(dir, &["go.mod"]) {
Some(BuildSystem::Go)
} else if has(dir, &["tsconfig.json", "package.json"]) {
Some(BuildSystem::TypeScript)
@@ -232,7 +227,7 @@ pub fn plan(
BuildSystem::Gradle => gradle_steps(root, model, kind, module_dir.as_deref())?,
BuildSystem::Cargo => cargo_steps(&dir, kind),
// Go builds the file's package (a module holds many).
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind)?,
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
BuildSystem::TypeScript => ts_steps(&dir, kind),
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
};
@@ -263,12 +258,7 @@ pub(super) fn maven_steps(
return Err(IdeError::not_found("no Maven project found"));
}
let step = |p: &maven::MavenProject, extra: &[String]| {
let mut s = BuildStep::new(
&p.command,
&["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG],
&subdir(root, &p.dir),
Format::Maven,
);
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
s.args.extend(p.options.iter().cloned());
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
@@ -384,33 +374,7 @@ fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> V
steps
}
fn go_steps(dir: &Path, kind: BuildKind) -> IdeResult<Vec<BuildStep>> {
if dir.join("go.work").is_file() {
let work_file = dunce::canonicalize(dir.join("go.work"))?;
let modules = ide_core::go::work_modules(&std::fs::read_to_string(dir.join("go.work"))?);
if modules.is_empty() {
return Err(IdeError::invalid("The Go workspace contains no modules"));
}
let mut steps = Vec::new();
for module in modules {
let module = dir.join(module);
if !module.join("go.mod").is_file() {
return Err(IdeError::not_found(format!("Go workspace module has no go.mod: {}", module.display())));
}
// Go compares workspace membership against cwd paths lexically.
let module = dunce::canonicalize(module)?;
let mut module_steps = go_module_steps(&module, kind);
for step in &mut module_steps {
step.env.insert("GOWORK".into(), work_file.to_string_lossy().into_owned());
}
steps.extend(module_steps);
}
return Ok(steps);
}
Ok(go_module_steps(dir, kind))
}
fn go_module_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
let mut args = vec!["build"];
@@ -777,7 +741,13 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
has_non_java_sources(module)
}
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
// JDT may have left newer error stubs in Maven's output directories.
BuildKind::Rebuild
} else {
BuildKind::Build
};
let mut plan = plan(root, model, kind, Some(module), None)?;
if plan.system == BuildSystem::Maven && !test {
for step in &mut plan.steps {
@@ -789,12 +759,6 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind:
Ok(plan)
}
#[derive(Debug)]
pub struct LaunchBuild {
pub class_dirs: Vec<PathBuf>,
pub failure: Option<String>,
}
/// Compiles the module at `module` with its build system before a JVM launch and
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
pub fn build_for_launch(
@@ -802,12 +766,11 @@ pub fn build_for_launch(
model: &BuildModel,
module: &Path,
test: bool,
kind: BuildKind,
cancel: &CancellationToken,
) -> IdeResult<LaunchBuild> {
let plan = launch_plan(root, model, module, test, kind)?;
) -> IdeResult<Vec<PathBuf>> {
let plan = launch_plan(root, model, module, test)?;
let result = run(root, &plan, cancel, &|_| {})?;
let failure = if !result.success {
if !result.success {
let errors: Vec<String> = result
.messages
.iter()
@@ -818,21 +781,17 @@ pub fn build_for_launch(
_ => m.text.clone(),
})
.collect();
Some(format!("Build failed:\n{}", errors.join("\n")))
} else {
None
};
let class_dirs = if plan.system == BuildSystem::Maven
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
}
if plan.system == BuildSystem::Maven
&& let Some(m) = &model.maven
{
maven_launch_projects(root, m, module, test)
return Ok(maven_launch_projects(root, m, module, test)
.iter()
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
.collect()
} else {
launch_class_dirs(plan.system, module, test)
};
Ok(LaunchBuild { class_dirs, failure })
.collect());
}
Ok(launch_class_dirs(plan.system, module, test))
}
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
@@ -854,79 +813,6 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[test]
fn go_workspace_build_commands_compile_members_with_local_dependencies() {
let Some(go) = ide_core::process::find_executable("go", &ide_core::tool_path::search_path()) else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let root = &tmp.path().join("workspace");
write(root, "go.work", "go 1.22\nuse (\n ./app\n ./lib\n ../external\n)\n");
write(root, "lib/go.mod", "module example.com/lib\ngo 1.22\n");
write(root, "lib/lib.go", "package lib\nfunc Value() int { return 1 }\n");
write(root, "app/go.mod", "module example.com/app\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "app/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
write(root, "../external/go.mod", "module example.com/external\ngo 1.22\nrequire example.com/lib v0.0.0\n");
write(root, "../external/main.go", "package main\nimport \"example.com/lib\"\nfunc main(){_ = lib.Value()}\n");
// std canonicalization also exercises verbatim paths on Windows.
for root in [root.to_path_buf(), std::fs::canonicalize(root).unwrap()] {
let plan = plan(&root, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
for step in plan.steps {
let result = std::process::Command::new(&go)
.args(&step.args)
.envs(&step.env)
.env("GOPROXY", "off")
.current_dir(&step.cwd)
.output()
.unwrap();
assert!(result.status.success(), "{step:?}: {}", String::from_utf8_lossy(&result.stderr));
}
}
}
#[test]
fn go_workspace_builds_every_use_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("workspace");
write(&root, "go.work", "go 1.22\nuse (\n ./api\n \"./lib with spaces\"\n ../shared\n)\n");
for module in ["api", "lib with spaces", "../shared"] {
write(&root.join(module), "go.mod", "module example.com/p\ngo 1.22\n");
}
// Cover native paths, Windows verbatim paths and non-normalized module paths.
let canonical = std::fs::canonicalize(&root).unwrap();
for root in [&root, &canonical, &canonical.join("..").join("workspace")] {
for kind in [BuildKind::Build, BuildKind::Rebuild, BuildKind::Artifacts] {
let p = plan(root, &BuildModel::default(), kind, None, None).unwrap();
assert_eq!(p.system, BuildSystem::Go);
assert_eq!(p.steps.len(), 3);
for (step, module) in p.steps.iter().zip(["api", "lib with spaces", "../shared"]) {
assert_eq!(step.cwd, dunce::canonicalize(root.join(module)).unwrap());
assert_eq!(
step.env["GOWORK"],
dunce::canonicalize(root.join("go.work")).unwrap().to_string_lossy()
);
let expected =
if kind == BuildKind::Rebuild { vec!["build", "-a", "./..."] } else { vec!["build", "./..."] };
assert_eq!(step.args, expected);
}
}
}
let member =
plan(&root, &BuildModel::default(), BuildKind::Build, Some(&root.join("api/pkg/a.go")), None).unwrap();
assert_eq!(member.steps.len(), 1);
assert_eq!(member.steps[0].cwd, root.join("api"));
assert_eq!(member.steps[0].args, ["build", "./pkg/..."]);
write(&root, "go.work", "use ./missing\n");
assert!(
plan(&root, &BuildModel::default(), BuildKind::Build, None, None)
.unwrap_err()
.to_string()
.contains("go.mod")
);
write(&root, "go.work", "go 1.22\n");
assert!(plan(&root, &BuildModel::default(), BuildKind::Build, None, None).is_err());
}
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
@@ -1049,21 +935,9 @@ mod tests {
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(
p.steps[0].args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-pl",
"core",
"-am",
"clean",
"test-compile"
]
);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
assert_eq!(p.steps[0].cwd, root);
assert_eq!(p.title, "demo: build module 'core'");
@@ -1331,42 +1205,14 @@ mod tests {
write(root, "app/pom.xml", "<project/>");
let model = reactor_model();
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
let step = &plan.steps[0];
assert_eq!(step.cwd, root);
assert_eq!(step.program, "mvnw");
assert_eq!(step.env["JAVA_HOME"], "/jdk");
assert_eq!(
step.args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-pl",
"app",
"-am",
goal
]
);
// Rebuild cleans first, as IDEA's Rebuild does.
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
assert_eq!(
plan.steps[0].args,
[
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-pl",
"app",
"-am",
"clean",
goal
]
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
);
}
}
@@ -1387,10 +1233,9 @@ mod tests {
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut model = reactor_model();
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
.unwrap();
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
assert_eq!(
dirs.class_dirs,
dirs,
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
.map(|p| root.join(p))
);
@@ -1399,16 +1244,12 @@ mod tests {
"mvnw",
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
);
let error =
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
.unwrap()
.failure
.unwrap();
assert!(error.contains("Kotlin compilation failed"), "{error}");
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
ide_core::error::ErrorKind::Cancelled
);
}
+9 -641
View File
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
/// Quoted string literals in `s`.
fn literals(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
while let Some(start) = rest.find(['"', '\'']) {
let Some((value, used)) = literal(&rest[start..]) else { break };
out.push(value);
rest = &rest[start + used..];
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
out.push(chars.by_ref().take_while(|&n| n != c).collect());
}
}
out
}
@@ -565,12 +565,11 @@ fn is_dependency_configuration(name: &str) -> bool {
/// `(group, artifact, version)` of the external dependencies a build script declares:
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
let text = strip_comments(text);
let mut out = Vec::new();
for line in text.lines().flat_map(statements) {
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
let line = line.trim_start();
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
if name.is_empty() || !is_dependency_configuration(&name) {
@@ -592,7 +591,6 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
}),
};
if let Some((g, a, v)) = coordinate {
let v = interpolate(&v, properties);
let plain = |s: &str| {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
};
@@ -604,354 +602,11 @@ pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) ->
out
}
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
fn statements(line: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut index = 0;
while index < line.len() {
let c = line[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
continue;
}
if matches!(c, '{' | '}' | ';' | '\n') {
out.push(&line[start..index]);
start = index + c.len_utf8();
}
index += c.len_utf8();
}
out.push(&line[start..]);
out
}
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
/// targets, as written in the script.
pub fn applied_scripts(text: &str) -> Vec<String> {
let text = strip_comments(text);
let mut out = Vec::new();
for (index, word) in code_words(&text) {
if word != "apply" || !starts_word(&text, index) {
continue;
}
let mut rest = text[index + "apply".len()..].trim_start();
if let Some(inner) = rest.strip_prefix('(') {
rest = inner.trim_start();
}
let Some(rest) = rest.strip_prefix("from") else { continue };
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
continue;
};
let mut rest = rest.trim_start();
if let Some(inner) = rest.strip_prefix("file") {
let inner = inner.trim_start();
if let Some(inner) = inner.strip_prefix('(') {
rest = inner.trim_start();
}
}
if let Some((path, _)) = literal(rest) {
out.push(path);
}
}
out
}
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
let text = strip_comments(text);
let mut out = BTreeMap::new();
for (index, word) in code_words(&text) {
if !starts_word(&text, index) {
continue;
}
let rest = text[index + word.len()..].trim_start();
if word == "val" || word == "var" {
let (name, rest) = identifier(rest);
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
if let Some(rest) = rest.strip_prefix('(')
&& let Some((value, used)) = literal(rest.trim_start())
&& rest.trim_start()[used..].trim_start().starts_with(')')
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
continue;
}
if word != "ext" && word != "extra" {
continue;
}
if let Some(block) = rest.strip_prefix('{') {
if let Some(end) = matching_brace(block) {
out.extend(assignments(&block[..end]));
}
} else if let Some(rest) = rest.strip_prefix('.') {
let (name, rest) = identifier(rest);
if let Some(value) = assigned_literal(rest)
&& !name.is_empty()
{
out.insert(name.to_owned(), value);
}
} else if let Some(rest) = rest.strip_prefix('[')
&& let Some((name, used)) = literal(rest.trim_start())
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
&& let Some(value) = assigned_literal(rest)
{
out.insert(name, value);
}
}
out
}
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with(['#', '!']) {
return None;
}
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
let (key, rest) = line.split_at(separator);
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
})
.collect()
}
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
/// read from disk: absolute path and text. Targets that do not exist are skipped.
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
const MAX_SCRIPTS: usize = 256;
const MAX_DEPTH: usize = 32;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let first = normalize(&root.join(build_file));
let Ok(identity) = dunce::canonicalize(&first) else { return out };
seen.insert(identity);
let mut stack = vec![(first, 0)];
while let Some((path, depth)) = stack.pop() {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if depth < MAX_DEPTH {
let mut targets = Vec::new();
for spec in applied_scripts(&text) {
if seen.len() >= MAX_SCRIPTS {
break;
}
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
&& let Ok(identity) = dunce::canonicalize(&target)
&& seen.insert(identity)
{
targets.push((target, depth + 1));
}
}
stack.extend(targets.into_iter().rev());
}
out.push((path, text));
}
out
}
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
let spec = spec.trim();
if spec.contains("://") {
return None;
}
let properties = BTreeMap::from([
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
]);
let spec = interpolate(spec, &properties);
if spec.contains('$') {
return None;
}
let path = Path::new(&spec);
if path.is_absolute() {
return path.is_file().then(|| normalize(path));
}
let project_path = normalize(&project_dir.join(path));
if project_path.is_file() {
return Some(project_path);
}
let path = normalize(&script.parent()?.join(path));
path.is_file().then_some(path)
}
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
root.join(dir.map_or(&model.dir, |p| &p.dir))
}
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
/// expressions are left as written, so ranges and catalogs stay skipped.
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
if !value.contains('$') {
return value.to_owned();
}
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
if braced {
for n in chars.by_ref() {
if n == '}' {
break;
}
name.push(n);
}
} else {
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || n == '_' || n == '.' {
name.push(n);
chars.next();
} else {
break;
}
}
}
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
let (key, used) = literal(rest.trim_start())?;
(rest.trim_start()[used..].trim() == "]").then_some(key)
});
match properties.get(bracket.as_deref().unwrap_or(key)) {
Some(v) if !name.is_empty() => out.push_str(v),
_ => {
out.push('$');
if braced {
out.push('{');
}
out.push_str(&name);
if braced {
out.push('}');
}
}
}
}
out
}
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
fn starts_word(text: &str, index: usize) -> bool {
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
}
/// Identifiers outside quoted strings, with their byte positions.
fn code_words(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next().unwrap();
if c == '"' || c == '\'' {
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
} else if c.is_alphabetic() || c == '_' {
let (word, _) = identifier(&text[index..]);
out.push((index, word));
index += word.len();
} else {
index += c.len_utf8();
}
}
out
}
fn identifier(text: &str) -> (&str, &str) {
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
text.split_at(end)
}
fn assigned_literal(text: &str) -> Option<String> {
let rest = text.trim_start().strip_prefix('=')?.trim_start();
let (value, used) = literal(rest)?;
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
}
/// The quoted string at the start of `s`, and how many bytes it spans.
fn literal(s: &str) -> Option<(String, usize)> {
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &s[quote.len_utf8()..];
let mut index = 0;
while index < rest.len() {
let c = rest[index..].chars().next()?;
if c == quote {
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
}
if c == '\\' {
index += c.len_utf8();
index += rest[index..].chars().next()?.len_utf8();
} else if quote == '"' && rest[index..].starts_with("${") {
index += 2;
index += matching_brace(&rest[index..])? + 1;
} else {
index += c.len_utf8();
}
}
None
}
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
fn matching_brace(text: &str) -> Option<usize> {
let mut depth = 1usize;
let mut index = 0;
while index < text.len() {
let c = text[index..].chars().next()?;
if c == '"' || c == '\'' {
index += literal(&text[index..])?.1;
continue;
}
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
index += c.len_utf8();
}
None
}
/// `name = 'value'` pairs in `text`.
fn assignments(text: &str) -> Vec<(String, String)> {
statements(text)
.into_iter()
.filter_map(|statement| {
let (name, rest) = identifier(statement.trim_start());
let value = assigned_literal(rest)?;
(!name.is_empty()).then(|| (name.to_owned(), value))
})
.collect()
}
/// Every settings and build script of the model (for change detection).
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
let dir = root.join(&model.dir);
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
// Scripts a build script applies are build files too: editing one can change the model.
for project in &model.projects {
let Some(file) = project.build_file.as_deref() else { continue };
let build_dir = project_build_dir(root, model, project);
let properties = build_dir.join("gradle.properties");
if properties.is_file() {
out.push(properties);
}
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
}
// The settings of each nested build (its root project's folder).
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
let nested = root.join(&p.dir);
@@ -969,8 +624,6 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
catalogs.sort();
out.extend(catalogs);
}
out.sort();
out.dedup();
out
}
@@ -1155,7 +808,7 @@ dependencies {
classpath 'not:a:library'
}
"#;
let deps = script_dependencies(script, &BTreeMap::new());
let deps = script_dependencies(script);
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
assert_eq!(
names,
@@ -1169,291 +822,6 @@ dependencies {
);
}
#[test]
fn applied_scripts_read_groovy_and_kotlin_forms() {
let script = r#"
apply plugin: 'java'
apply from: 'gradle/dependencies.gradle'
apply from: file('gradle/extra.gradle')
apply(from = "gradle/kotlin.gradle.kts")
apply(from = file("gradle/kotlin-file.gradle.kts"))
// apply from: 'commented.gradle'
reapply from: 'not-a-call.gradle'
"#;
assert_eq!(
applied_scripts(script),
[
"gradle/dependencies.gradle",
"gradle/extra.gradle",
"gradle/kotlin.gradle.kts",
"gradle/kotlin-file.gradle.kts"
]
);
}
#[test]
fn ext_values_fill_in_dependency_versions() {
let script = r#"
ext {
gsonVersion = '2.8.9'
junit = "4.13.2"
}
ext.logback = '1.4.14'
dependencies {
implementation "com.google.code.gson:gson:${gsonVersion}"
testImplementation "junit:junit:$junit"
implementation "ch.qos.logback:logback-classic:${ext.logback}"
implementation 'org.plain:literal:1.0'
implementation "org.x:unknown:${missing}"
}
"#;
let names: Vec<String> = script_dependencies(script, &script_properties(script))
.into_iter()
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
.collect();
assert_eq!(
names,
[
"com.google.code.gson:gson:2.8.9",
"junit:junit:4.13.2",
"ch.qos.logback:logback-classic:1.4.14",
"org.plain:literal:1.0",
]
);
}
#[test]
fn extra_properties_support_brackets_and_kotlin_delegates() {
for declaration in [
"ext.x = '1.2'",
"ext { x = '1.2' }",
"ext[\"x\"] = '1.2'",
"extra[\"x\"] = \"1.2\"",
"val x by extra(\"1.2\")",
] {
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
assert_eq!(
script_dependencies(&script, &script_properties(&script)),
[("g".into(), "a".into(), "1.2".into())],
"{declaration}"
);
}
}
#[test]
fn parsers_ignore_apply_and_ext_inside_strings() {
let script = r#"
println "apply from: 'fake.gradle'"
println "ext.x = 'bogus'"
ext.real = '1'
"#;
assert!(applied_scripts(script).is_empty());
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
}
#[test]
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
assert_eq!(
script_dependencies(script, &properties),
[
("g".into(), "a".into(), "1.2".into()),
("g".into(), "b".into(), "1.2-RC1".into()),
("g".into(), "c".into(), "2".into())
]
);
assert_eq!(
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
);
}
#[test]
fn dependency_versions_skip_unresolved_names_and_expressions() {
let script = r#"dependencies {
implementation "g:missing:${unknown}"
implementation "g:expression:${1 + 2}"
implementation "g:cyclic:${cycle}"
implementation 'g:range:[1,2)'
implementation 'g:dynamic:1.+'
}"#;
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
assert!(script_dependencies(script, &properties).is_empty());
}
#[test]
fn extra_property_references_support_qualified_and_bracket_access() {
let script = r#"dependencies {
implementation "g:a:${ext.x}"
implementation "g:b:${extra.x}"
implementation "g:c:${ext['x']}"
implementation "g:d:${extra["x"]}"
}"#;
let properties = BTreeMap::from([("x".into(), "1".into())]);
assert_eq!(script_dependencies(script, &properties).len(), 4);
}
#[test]
fn applied_scripts_are_read_recursively_and_survive_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
.into_iter()
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
}
#[test]
fn applied_paths_prefer_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
std::fs::write(root.join("versions.gradle"), "right").unwrap();
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].1, "right");
}
#[test]
fn nested_applied_paths_are_relative_to_the_project_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
}
#[test]
fn applied_paths_fall_back_to_the_applying_script_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
assert_eq!(scripts.len(), 3);
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
}
#[test]
fn applied_paths_resolve_gradle_directory_variables() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let project = root.join("app");
let script = project.join("gradle/deps.gradle");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(root.join("shared.gradle"), "").unwrap();
std::fs::write(project.join("local.gradle"), "").unwrap();
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
Some(root.join("shared.gradle")),
"{prefix}"
);
}
for prefix in ["$projectDir", "${projectDir}"] {
assert_eq!(
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
Some(project.join("local.gradle")),
"{prefix}"
);
}
assert_eq!(
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
Some(root.join("shared.gradle"))
);
}
#[test]
fn applied_scripts_skip_urls_and_missing_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Even a local path resembling a URL must not be read.
if !cfg!(windows) {
for scheme in ["http", "https"] {
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
}
}
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(
root.join("build.gradle"),
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
)
.unwrap();
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
}
#[cfg(unix)]
#[test]
fn applied_scripts_deduplicate_symlink_cycles() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
}
#[test]
fn applied_script_traversal_bounds_depth_and_total_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for i in 0..300 {
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
}
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
assert_eq!(chain.len(), 33);
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
std::fs::write(root.join("build.gradle"), fanout).unwrap();
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
}
#[test]
fn applied_scripts_use_their_composite_build_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("included/app")).unwrap();
std::fs::write(root.join("build.gradle"), "").unwrap();
std::fs::write(root.join("included/build.gradle"), "").unwrap();
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
let mut model = load(root).unwrap();
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
project.build = Some(":included".into());
model.projects.push(project);
}
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
let model = crate::BuildModel { maven: None, gradle: vec![model] };
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
}
use super::*;
#[test]
+1 -11
View File
@@ -528,17 +528,7 @@ mod tests {
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(
step.args,
[
"-B",
"-Dstyle.color=never",
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"test-compile"
]
);
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
+7 -82
View File
@@ -76,7 +76,7 @@ pub fn load_linked(
gradle_settings: &ide_core::settings::GradleSettings,
) -> BuildModel {
let maven = maven::has_projects(root, settings).then(|| {
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
let profiles = sync::maven_profiles(cache_dir);
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
@@ -147,8 +147,7 @@ pub struct DependencyCoordinate {
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
/// (with the transitive ones the last sync read), except the build's own modules,
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
/// including the scripts they pull in with `apply from`.
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
let mut out = Vec::new();
if let Some(m) = &model.maven {
@@ -178,26 +177,11 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
}
}
for g in &model.gradle {
for p in &g.projects {
let Some(build_file) = p.build_file.as_deref() else { continue };
let build_dir = gradle::project_build_dir(root, g, p);
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
// The script's own `ext` values win over the scripts it applies.
let mut properties = std::collections::BTreeMap::new();
for (_, text) in scripts.iter().rev() {
properties.extend(gradle::script_properties(text));
}
properties.extend(gradle::file_properties(&build_dir));
for (_, text) in &scripts {
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|(group_id, artifact_id, version)| DependencyCoordinate {
gradle: true,
group_id,
artifact_id,
version,
},
));
}
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
}));
}
}
out.sort();
@@ -238,30 +222,6 @@ mod tests {
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
}
#[test]
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir(root.join("extra")).unwrap();
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(
root.join("pom.xml"),
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
)
.unwrap();
let cache = root.join("cache");
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
let modules = |settings: &ide_core::settings::MavenSettings| {
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
};
assert!(modules(&Default::default()).is_empty());
assert_eq!(modules(&settings), ["extra/pom.xml"]);
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
assert!(modules(&settings).is_empty());
}
#[test]
fn detects_build_systems_and_build_files() {
let tmp = tempfile::tempdir().unwrap();
@@ -328,39 +288,4 @@ mod tests {
.collect();
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
}
#[test]
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
std::fs::write(
root.join("gradle/dependencies.gradle"),
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
)
.unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.collect();
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
}
#[test]
fn gradle_properties_override_script_extra_values() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
std::fs::write(
root.join("deps.gradle"),
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
)
.unwrap();
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
assert_eq!(versions, ["1.2", "2.3"]);
}
}
+9 -44
View File
@@ -16,16 +16,6 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
pub mod sources;
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
/// has. Maven 3.9 re-resolves an artifact whose recorded origin repository (`_remote.repositories`)
/// isn't declared in the build, such as a vendor jar downloaded through another project or
/// installed by hand, and fails when no declared repository has it. Tracking under another
/// file name makes such artifacts count as locally installed. Maven goals the user runs keep
/// plain Maven's behaviour.
pub const LOCAL_REPO_TRACKING_ARG: &str = "-Daether.enhancedLocalRepository.trackingFilename=openidea.repositories";
/// Phases shown under "Lifecycle" (IDEA's default list).
pub const LIFECYCLE: &[&str] =
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
@@ -81,9 +71,6 @@ pub struct MavenModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProject {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
pub pom: String,
/// Project-relative directory; `""` = the project root.
@@ -182,9 +169,6 @@ pub enum Omission {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProfile {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
pub id: String,
pub active_by_default: bool,
}
@@ -507,7 +491,7 @@ pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSetti
/// IDEA lists them): checked by default when `<activeProfiles>` names them or they are
/// `activeByDefault`.
pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
let Some(root) = xml::read_text(settings).as_deref().and_then(xml::parse) else {
let Some(root) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
return Vec::new();
};
let active: Vec<String> =
@@ -519,7 +503,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
}
profiles
}
@@ -527,7 +511,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
/// `<localRepository>` of a settings file, when it names one: the repository a build
/// with `-s <file>` resolves into, which is not necessarily the user's default.
pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
let text = xml::read_text(settings)?;
let text = std::fs::read_to_string(settings).ok()?;
let root = xml::parse(&text)?;
let repo = root.text_of("localRepository")?.trim().to_owned();
if repo.is_empty() {
@@ -553,7 +537,7 @@ impl Resolver {
fn pom(&mut self, path: &Path) -> Option<RawPom> {
self.poms
.entry(path.to_path_buf())
.or_insert_with(|| xml::read_text(path).as_deref().and_then(parse_pom))
.or_insert_with(|| std::fs::read_to_string(path).ok().and_then(|t| parse_pom(&t)))
.clone()
}
@@ -744,7 +728,7 @@ pub(crate) fn launch_inputs(
return None;
}
out.push(path.clone());
let text = xml::read_text(&path)?;
let text = std::fs::read_to_string(&path).ok()?;
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
let raw = resolver.pom(&path)?;
let Some(parent) = raw.parent else { break };
@@ -1079,7 +1063,7 @@ pub fn load(root: &Path) -> MavenModel {
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1228,12 +1212,11 @@ pub fn load_with_linked(
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
}
}
let artifact_id = i(&pom.artifact_id);
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
@@ -1648,7 +1631,7 @@ fn parent_chain(path: &Path, text: &str, local_repo: Option<PathBuf>) -> Vec<(Pa
let Some(parent) = pom.and_then(|p| p.parent) else { return chain };
let Some(pp) = resolver.parent_path(&dir, &parent) else { return chain };
let pp = dunce::canonicalize(&pp).unwrap_or(pp);
let Some(next) = xml::read_text(&pp) else { return chain };
let Ok(next) = std::fs::read_to_string(&pp) else { return chain };
pom = resolver.pom(&pp);
dir = pp.parent().unwrap_or(Path::new("")).to_path_buf();
chain.push((pp, next));
@@ -1853,7 +1836,7 @@ mod tests {
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
@@ -2305,24 +2288,6 @@ mod tests {
assert!(!is_maven_project(&root.join("c")));
}
#[test]
fn non_utf8_pom_with_declared_encoding_is_a_build() {
// Issue #2439: a GBK `pom.xml` is not valid UTF-8; it must still load
// instead of vanishing as "No maven project".
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (bytes, _, _) = encoding_rs::GBK.encode(
"<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><groupId>g</groupId>\
<artifactId>应用</artifactId><version>1</version><name>演示</name></project>",
);
std::fs::write(root.join("pom.xml"), bytes.as_ref()).unwrap();
let model = load_with_repo(root, None);
assert_eq!(model.projects.len(), 1);
assert_eq!(model.projects[0].artifact_id, "应用");
assert_eq!(model.projects[0].display_name, "演示");
assert!(is_maven_project(root));
}
#[test]
fn aggregate_pom_in_src_is_a_build() {
// Issue #1001: `src/pom.xml` aggregating modules under `src/` is recognized,
-124
View File
@@ -1,124 +0,0 @@
//! On-demand sources resolution using the project's Maven launcher and repositories.
use super::MavenProject;
use crate::process;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
pub struct Artifact {
pub group: String,
pub name: String,
pub version: String,
pub sources: PathBuf,
}
/// Resolve only jars inside the selected local repository, including custom repositories.
pub fn artifact(jar: &Path, repository: &Path) -> Option<Artifact> {
let relative = jar.strip_prefix(repository).ok()?;
let parts: Vec<&str> = relative
.components()
.map(|c| match c {
Component::Normal(s) => s.to_str(),
_ => None,
})
.collect::<Option<_>>()?;
if parts.len() < 4 {
return None;
}
let n = parts.len();
let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]);
let valid = |s: &str| {
!s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
};
if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") {
return None;
}
Some(Artifact {
group: parts[..n - 3].join("."),
name: name.into(),
version: version.into(),
sources: jar.with_file_name(format!("{name}-{version}-sources.jar")),
})
}
pub fn download(
root: &Path,
project: &MavenProject,
artifact: &Artifact,
repository: &Path,
cancel: &CancellationToken,
) -> IdeResult<Option<PathBuf>> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()];
args.extend(project.options.clone());
args.extend([
format!("-Dmaven.repo.local={}", repository.display()),
"org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(),
format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version),
"-Dtransitive=false".into(),
]);
let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?;
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if artifact.sources.is_file() {
return Ok(Some(artifact.sources.clone()));
}
let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version);
if output.success
|| output.text.lines().any(|line| {
line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found"))
})
{
return Ok(None);
}
Err(IdeError::unavailable(
output.text.lines().rev().take(20).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_sources_coordinates_from_maven_layouts() {
for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] {
for (group, name, version) in [
("org/apache/commons", "commons-lang3", "3.17.0"),
("com/example", "example-lib", "1.0-SNAPSHOT"),
("single", "lib", "1.0.0-rc.1"),
] {
let repository = Path::new(repository);
let dir = repository.join(group).join(name).join(version);
let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap();
assert_eq!(found.group, group.replace('/', "."));
assert_eq!(found.name, name);
assert_eq!(found.version, version);
assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar")));
}
}
}
#[test]
fn rejects_non_artifacts_and_paths_outside_the_repository() {
let repository = Path::new("/m2/repository");
for path in [
"/m2/repository-other/g/a/1/a-1.jar",
"/m2/repository/g/a/1/a-1-sources.jar",
"/m2/repository/g/a/1/other.jar",
"/m2/repository/g/a/../a-1.jar",
"/m2/repository/g/a/1/a-1.pom",
"/m2/repository/g/a-1.jar",
"/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar",
] {
assert_eq!(artifact(Path::new(path), repository), None, "{path}");
}
}
}
+2 -42
View File
@@ -304,7 +304,7 @@ pub fn sync_maven(
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.maven_settings.profiles(&options.profiles),
&options.profiles,
&options.maven_settings.linked_projects,
);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
@@ -341,13 +341,7 @@ pub fn sync_maven(
// `-T1` keeps mvnd serial: its default parallel build interleaves the
// `dependency:tree` output of concurrent modules, which the sync parses
// as one stream. mvn is serial anyway, so this only pins the daemon.
let mut args: Vec<String> = vec![
"-B".into(),
"-fae".into(),
"-Dstyle.color=never".into(),
"-T1".into(),
maven::LOCAL_REPO_TRACKING_ARG.into(),
];
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
args.extend(sync_maven_options(&options.maven_options));
if options.offline {
args.push("-o".into());
@@ -824,40 +818,6 @@ mod tests {
assert!(m.stale, "gradle.properties is watched");
}
#[test]
fn applied_script_edits_invalidate_the_cached_gradle_model() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(root.join("gradle")).unwrap();
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
let applied = root.join("gradle/dependencies.gradle");
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
let model = gradle::load(&root).unwrap();
let cache = GradleCache {
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
projects: vec![ReportedProject {
path: ":".into(),
dir: root.to_string_lossy().into_owned(),
..Default::default()
}],
..Default::default()
};
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
let stale = || {
let mut model = gradle::load(&root).unwrap();
apply_gradle_cache(&root, &cache_dir, &mut model);
model.stale
};
assert!(!stale());
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
assert!(!stale());
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
assert!(stale());
std::fs::remove_file(&applied).unwrap();
assert!(stale());
}
#[test]
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
let online = maven_sync_goals(Path::new("e.xml"), false);
-66
View File
@@ -3,57 +3,6 @@
//! skipped. Namespace prefixes are dropped (`<x:project>` → `project`).
//! Elements keep their byte offsets in the source, for edits ([`crate::pom_edit`]).
use encoding_rs::Encoding;
use std::path::Path;
/// Reads an XML file, honoring its `encoding="…"` declaration: a GBK POM
/// (issue #2439) is not valid UTF-8, and `read_to_string` would drop the
/// whole project as "No maven project" while CLI Maven reads it fine.
pub fn read_text(path: &Path) -> Option<String> {
std::fs::read(path).ok().map(|bytes| decode_bytes(&bytes))
}
/// Decodes XML bytes: BOM first, then the `encoding` declaration (ASCII in
/// every single-byte encoding, so the head scans before decoding), then
/// UTF-8, then a chardetng guess.
pub fn decode_bytes(bytes: &[u8]) -> String {
if let Some((enc, len)) = Encoding::for_bom(bytes) {
let (text, _) = enc.decode_without_bom_handling(&bytes[len..]);
return text.into_owned();
}
if let Some(enc) = declared_encoding(bytes) {
let (text, _) = enc.decode_without_bom_handling(bytes);
return text.into_owned();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_owned();
}
let mut detector = chardetng::EncodingDetector::new();
detector.feed(bytes, true);
let (text, _) = detector.guess(None, true).decode_without_bom_handling(bytes);
text.into_owned()
}
fn declared_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let head = std::str::from_utf8(bytes.get(..bytes.len().min(1024))?).ok()?;
let mut rest = head.split("?>").next().unwrap_or(head);
while let Some(i) = rest.find("encoding") {
rest = &rest[i + "encoding".len()..];
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let (&quote, after) = value.as_bytes().split_first()?;
if quote != b'"' && quote != b'\'' {
continue;
}
let after = std::str::from_utf8(after).ok()?;
let end = after.find(quote as char)?;
if let Some(enc) = Encoding::for_label(after[..end].trim().as_bytes()) {
return Some(enc);
}
}
None
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Element {
pub name: String,
@@ -261,19 +210,4 @@ mod tests {
assert!(parse("").is_none());
assert!(parse("just text").is_none());
}
#[test]
fn decodes_the_declared_encoding() {
// Issue #2439: a GBK POM is not valid UTF-8; decoding must honor the declaration.
let (bytes, _, _) =
encoding_rs::GBK.encode("<?xml version=\"1.0\" encoding=\"GBK\"?>\n<project><name>中文</name></project>");
let text = decode_bytes(&bytes);
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("中文"));
// An unknown label falls back to UTF-8 rather than mis-decoding.
let text = decode_bytes(b"<?xml version=\"1.0\" encoding=\"X-UNKNOWN\"?>\n<project><name>ok</name></project>");
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("ok"));
// No declaration: plain UTF-8 passes through untouched.
let text = decode_bytes("<project><name>plain</name></project>".as_bytes());
assert_eq!(parse(&text).unwrap().text_of("name").as_deref(), Some("plain"));
}
}
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "ide-cli"
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
tracing.workspace = true
[target.'cfg(windows)'.dependencies]
tokio = { workspace = true, features = ["net"] }
winreg = "0.56"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
tempfile.workspace = true
-234
View File
@@ -1,234 +0,0 @@
//! Command-line arguments of `openidea`.
use crate::Target;
use std::path::Path;
pub const USAGE: &str = "\
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
openidea diff <left> <right>
Opens folders as projects and files in the editor of the running OpenIDEA,
starting it if needed. Without paths, brings OpenIDEA to the front.
Lines and columns start at 1; --line and --column apply to the next path.
Options:
--line <n> Put the caret on line <n> of the next file
--column <n> Put the caret in column <n> of that line
-h, --help Show this help
-v, --version Show the version
";
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Help,
Version,
Open(Vec<Target>),
Diff(Vec<Target>),
}
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
parse_with(args, cwd, &|p| p.exists())
}
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
if args.first().is_some_and(|arg| arg == "diff") {
return match parse_paths(&args[1..], cwd, exists)? {
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
Command::Help => Ok(Command::Help),
_ => Err("diff needs two files".into()),
};
}
parse_paths(args, cwd, exists)
}
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
let mut targets = Vec::new();
let mut options = true;
let (mut line, mut column) = (None, None);
let mut args = args.iter();
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
_ => (arg.as_str(), None),
};
match flag {
"--" if options => options = false,
"-h" | "--help" if options => return Ok(Command::Help),
"-v" | "--version" if options => return Ok(Command::Version),
"--line" | "--column" if options => {
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
}
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
_ => {
let mut target = target(arg, cwd, exists)?;
if line.is_some() || column.is_some() {
target.line = line.take().or(target.line).or(Some(1));
target.column = column.take();
}
targets.push(target);
}
}
}
if line.is_some() || column.is_some() {
return Err("--line and --column need a file after them".into());
}
Ok(Command::Open(targets))
}
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
let path = cwd.join(arg);
if exists(&path) {
return Ok(Target { path, line: None, column: None });
}
for (base, line, column) in split_position(arg) {
let path = cwd.join(base);
if exists(&path) {
return Ok(Target { path, line: Some(line), column });
}
}
Err(format!("{arg}: no such file or directory"))
}
/// The readings of `arg` as `path:line:column`, then as `path:line`.
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
let Some(last) = position(last) else { return Vec::new() };
let mut readings = Vec::new();
if let Some((base, line)) = rest.rsplit_once(':')
&& let Some(line) = position(line)
&& !base.is_empty()
{
readings.push((base, line, Some(last)));
}
if !rest.is_empty() {
readings.push((rest, last, None));
}
readings
}
fn position(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
}
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
Target { path: path.into(), line, column }
}
#[test]
fn paths_resolve_against_cwd() {
assert_eq!(
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
);
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
}
#[test]
fn positions_split_only_when_the_whole_arg_is_missing() {
let files = ["/work/a.rs"];
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
assert!(parse_in(&["a.rs:0"], &files).is_err());
assert!(parse_in(&["a.rs:x"], &files).is_err());
assert!(parse_in(&["missing.rs:3"], &files).is_err());
}
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
#[cfg(unix)]
#[test]
fn an_existing_name_with_a_colon_wins_over_a_position() {
let files = ["/work/b:2"];
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
}
#[test]
fn windows_drive_colon_is_not_a_position() {
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
assert!(split_position(r"C:\a.rs").is_empty());
}
#[cfg(windows)]
#[test]
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
let local = PathBuf::from(r"C:\项目\my file.rs");
let network = PathBuf::from(r"\\server\share\folder name");
let command = parse_with(
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
Path::new(r"D:\work"),
&|path| path == local || path == network,
);
assert_eq!(
command,
Ok(Command::Open(vec![
Target { path: local, line: Some(12), column: Some(5) },
Target { path: network, line: None, column: None },
]))
);
}
#[test]
fn line_and_column_flags_apply_to_the_next_path() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
);
assert_eq!(
parse_in(&["--line=3", "a.rs:9:9"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
);
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
assert!(parse_in(&["--line"], &files).is_err());
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
}
#[test]
fn options() {
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
assert!(parse_in(&["--nope"], &[]).is_err());
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
}
#[test]
fn existing_diff_command_is_supported() {
let files = ["/work/a.rs", "/work/b.rs"];
assert_eq!(
parse_in(&["diff", "a.rs", "b.rs"], &files),
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
);
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
assert_eq!(
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
);
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
assert_eq!(
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
);
}
}
-476
View File
@@ -1,476 +0,0 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in a macOS bundle, or a wrapper in a user's bin directory.
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
pub const COMMAND: &str = "openidea";
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
const SYSTEM_DIR: &str = "/usr/local/bin";
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
launcher.is_file().then_some(launcher)
}
/// macOS runs a quarantined app from a random read-only copy until it is moved:
/// a link there would break on the next launch.
pub fn translocated(path: &Path) -> bool {
path.to_string_lossy().contains("/AppTranslocation/")
}
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
/// rights needed), else `/usr/local/bin`.
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
let local = home.map(|h| h.join(".local/bin"));
match (path_var, local) {
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
_ => PathBuf::from(SYSTEM_DIR),
}
}
/// Directories an earlier install may have used.
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
}
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
}
fn is_launcher_link(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
}
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
/// is not writable. Refuses to replace an `openidea` that is not ours.
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
let link = dir.join(COMMAND);
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", link.display()),
));
}
let direct = std::fs::create_dir_all(dir).and_then(|()| {
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link)?;
}
symlink(launcher, &link)
});
match direct {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
}
result => result?,
}
Ok(link)
}
/// Removes `links`, asking for administrator rights where needed.
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
for link in links {
match std::fs::remove_file(link) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
result => result?,
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
}
/// Runs `command` as root after the system's password prompt. Cancelling it is
/// [`io::ErrorKind::Interrupted`].
fn privileged(command: &str) -> io::Result<()> {
if !cfg!(target_os = "macos") {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
}
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
// "User canceled. (-128)"
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
Err(io::Error::new(kind, message))
}
fn quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
fn applescript_escape(s: &str) -> String {
s.replace('\\', r"\\").replace('"', "\\\"")
}
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
/// AppImages remount their executable on every launch. Keep the original image path.
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
let path = appimage.unwrap_or(exe);
if !path.is_absolute() || !path.is_file() {
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
}
Ok(path.to_path_buf())
}
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
dir.join(if windows { "openidea.cmd" } else { COMMAND })
}
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
let exe =
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
}
Ok(if windows {
format!(
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
exe.replace('%', "%%")
)
} else {
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
})
}
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
&& std::fs::read_to_string(path).is_ok_and(|text| {
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
})
}
/// Never follows or overwrites a foreign file, even if it has the same command name.
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
use std::io::Write;
let text = wrapper(exe, windows)?;
let path = wrapper_path(dir, windows);
if path.symlink_metadata().is_ok() {
if !is_wrapper(&path, windows) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", path.display()),
));
}
std::fs::remove_file(&path)?;
}
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
}
file.write_all(text.as_bytes())?;
Ok(path)
}
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
let path = wrapper_path(dir, windows);
if !is_wrapper(&path, windows) {
return Ok(vec![]);
}
std::fs::remove_file(&path)?;
Ok(vec![path])
}
/// Preserve every unrelated PATH entry, including expandable environment variables.
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
fn equal(a: &str, b: &str) -> bool {
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
}
let found = current.split(';').any(|entry| equal(entry, dir));
if add {
if found {
return None;
}
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
} else if found {
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
} else {
None
}
}
#[cfg(windows)]
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
use winreg::{
RegKey,
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
types::{FromRegValue, ToRegValue},
};
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
let raw = match environment.get_raw_value("Path") {
Ok(raw) => Some(raw),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
let Some(updated) = windows_path(&current, &dir.to_string_lossy(), add) else { return Ok(()) };
let mut value = updated.to_reg_value();
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
environment.set_raw_value("Path", &value)?;
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
unsafe {
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
0,
message.as_ptr() as isize,
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
1000,
std::ptr::null_mut(),
);
}
Ok(())
}
pub struct Installation {
pub path: PathBuf,
/// Some when the user needs to add this directory to their shell's PATH.
pub path_directory: Option<PathBuf>,
}
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
path_var
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
.is_some_and(|candidate| candidate == command)
}
fn user_bin() -> io::Result<PathBuf> {
if cfg!(windows) {
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
} else {
dirs::home_dir().map(|dir| dir.join(".local/bin"))
}
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
}
pub fn install_current() -> io::Result<Installation> {
let exe = std::env::current_exe()?;
if cfg!(target_os = "macos") {
let launcher = bundled_launcher(&exe).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
})?;
if translocated(&launcher) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"move OpenIDEA to the Applications folder first",
));
}
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
}
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
let exe = stable_executable(&exe, image.as_deref())?;
let dir = user_bin()?;
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
#[cfg(windows)]
update_user_path(&dir, true)?;
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
}
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
if cfg!(target_os = "macos") {
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
uninstall(&links)?;
return Ok(links);
}
let dir = user_bin()?;
let path = wrapper_path(&dir, cfg!(windows));
if is_wrapper(&path, cfg!(windows)) {
#[cfg(windows)]
update_user_path(&dir, false)?;
return uninstall_wrapper(&dir, cfg!(windows));
}
Ok(vec![])
}
#[cfg(test)]
mod wrapper_tests {
use super::*;
#[test]
fn wrappers_install_update_and_remove_only_their_own_files() {
for windows in [false, true] {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
assert!(is_wrapper(&path, windows));
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
std::fs::write(&path, "foreign command").unwrap();
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
}
}
#[test]
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
let dir = r"C:\Users\用户\OpenIDEA\bin";
let initial = r"%USERPROFILE%\tools;C:\other";
let updated = windows_path(initial, dir, true).unwrap();
assert_eq!(updated, format!("{dir};{initial}"));
assert_eq!(windows_path(&updated, dir, true), None);
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
assert_eq!(
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
Some(r"C:\other")
);
assert_eq!(windows_path(initial, dir, false), None);
}
#[test]
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("OpenIDEA.AppImage");
std::fs::write(&image, "image").unwrap();
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
assert_eq!(stable_executable(&image, None).unwrap(), image);
assert!(stable_executable(Path::new("missing"), None).is_err());
}
#[test]
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
let dir = tempfile::tempdir().unwrap();
let system = dir.path().join("system");
let user = dir.path().join("user");
for bin in [&system, &user] {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(bin.join(COMMAND), "command").unwrap();
}
let path = std::env::join_paths([&system, &user]).unwrap();
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
let path = std::env::join_paths([&user, &system]).unwrap();
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
}
#[cfg(unix)]
#[test]
fn posix_wrapper_preserves_arguments_and_exit_status() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
}
#[cfg(windows)]
#[test]
fn cmd_wrapper_preserves_arguments_and_exit_status() {
use std::os::windows::process::CommandExt;
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("程序 & 100% !special!.exe");
let source = dir.path().join("helper.rs");
std::fs::write(
&source,
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
)
.unwrap();
let compiled = std::process::Command::new("rustc")
.args(["--crate-name", "launcher_test"])
.arg(&source)
.arg("-o")
.arg(&exe)
.output()
.unwrap();
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
let check = dir.path().join("check.cmd");
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\"\"", check.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
let app = root.join("OpenIDEA.app");
let exe = app.join("Contents/MacOS/openidea");
let launcher = app.join(LAUNCHER);
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&exe, "").unwrap();
std::fs::write(&launcher, "").unwrap();
(exe, launcher)
}
#[test]
fn finds_the_launcher_of_a_bundle_only() {
let dir = tempfile::tempdir().unwrap();
let (exe, launcher) = bundle(dir.path());
assert_eq!(bundled_launcher(&exe), Some(launcher));
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
}
#[test]
fn prefers_local_bin_on_path() {
let home = Path::new("/Users/u");
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
}
#[test]
fn install_replaces_its_own_link_but_not_other_files() {
let dir = tempfile::tempdir().unwrap();
let (_, launcher) = bundle(dir.path());
let bin = dir.path().join("bin");
let link = install(&launcher, &bin).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
assert_eq!(install(&launcher, &bin).unwrap(), link);
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
assert!(installed(std::slice::from_ref(&bin)).is_empty());
std::fs::write(&link, "someone else's").unwrap();
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn quoting() {
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
}
}
-72
View File
@@ -1,72 +0,0 @@
//! The `openidea` command.
//!
//! The command is the app's own executable run in CLI mode: it parses its
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
//! script into a directory on `PATH`.
pub mod args;
pub mod install;
pub mod socket;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bumped when [`Request`] changes incompatibly; an app still running an older
/// version answers with an error instead of misreading it.
pub const PROTOCOL_VERSION: u32 = 1;
/// A file or folder to open, with an optional 1-based position in a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
/// Absolute.
pub path: PathBuf,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// What the command asks the running app to do. No targets: bring the app to the front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Request {
pub version: u32,
pub targets: Vec<Target>,
#[serde(default)]
pub diff: bool,
}
impl Request {
pub fn open(targets: Vec<Target>) -> Self {
Self { version: PROTOCOL_VERSION, targets, diff: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Response {
Ok,
Error { message: String },
}
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
/// console for interactive help and errors. Batch launchers wait for this process.
#[cfg(windows)]
pub fn attach_console() {
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
use windows_sys::Win32::System::Console::{
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
SetStdHandle,
};
// SAFETY: attaches only this CLI process, without allocating a new console.
unsafe {
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
let handle = GetStdHandle(kind);
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
});
AttachConsole(ATTACH_PARENT_PROCESS);
for (kind, handle, keep) in redirected {
if keep {
SetStdHandle(kind, handle);
}
}
}
}
-266
View File
@@ -1,266 +0,0 @@
//! The local socket the running app listens on: one JSON [`Request`] line in,
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
use crate::{Request, Response};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10);
const MAX_MESSAGE: u64 = 1024 * 1024;
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
match serde_json::from_str::<Request>(line) {
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
handle(request)
}
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
Err(e) => Response::Error { message: format!("invalid request: {e}") },
}
}
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
/// socket paths are limited to about 100 bytes, and development identifiers are long.
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
if cfg!(windows) {
let user = dirs::data_local_dir()?;
return Some(PathBuf::from(format!(
r"\\.\pipe\openidea-{:016x}",
fnv1a(&format!("{}:{identifier}", user.display()))
)));
}
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
}
fn fnv1a(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
let stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
write_line(&stream, request)?;
let mut reply = String::new();
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
Ok(serde_json::from_str(&reply)?)
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let listener = bind(socket)?;
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
for stream in listener.incoming().flatten() {
if let Err(e) = serve_one(&stream, &handle) {
tracing::warn!("cli request failed: {e}");
}
}
})?;
Ok(())
}
/// Binds `socket`, replacing a stale file no one answers on.
fn bind(socket: &Path) -> io::Result<UnixListener> {
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = match UnixListener::bind(socket) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
return Err(e);
}
std::fs::remove_file(socket)?;
UnixListener::bind(socket)?
}
bound => bound?,
};
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut line = String::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
write_line(stream, &answer(&line, handle))
}
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
let mut line = serde_json::to_string(value)?;
line.push('\n');
stream.write_all(line.as_bytes())
}
}
#[cfg(windows)]
mod imp {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
fn runtime() -> io::Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread().enable_all().build()
}
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
runtime()?.block_on(async {
tokio::time::timeout(TIMEOUT, async {
let mut pipe = loop {
match ClientOptions::new().open(socket) {
Ok(pipe) => break pipe,
// All existing instances are busy; wait instead of starting another app.
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
Err(e) => return Err(e),
}
};
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
pipe.write_all(&line).await?;
let mut reply = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
pipe.write_all(b"\n").await?;
Ok(serde_json::from_str(&reply)?)
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
})
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let socket = socket.to_owned();
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
let runtime = match runtime() {
Ok(runtime) => runtime,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
runtime.block_on(async move {
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
Ok(pipe) => pipe,
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
let _ = ready.send(Ok(()));
loop {
if let Err(e) = pipe.connect().await {
tracing::warn!("CLI pipe connection failed: {e}");
return;
}
// Keep an instance bound while answering, so another process cannot take the name.
let next = match ServerOptions::new().create(&socket) {
Ok(next) => next,
Err(e) => {
tracing::warn!("cannot create CLI pipe: {e}");
return;
}
};
let reply = tokio::time::timeout(TIMEOUT, async {
let mut line = String::new();
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
reply.push(b'\n');
pipe.write_all(&reply).await?;
// Wait until the client has drained the reply before closing its instance.
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
})
.await;
if !matches!(reply, Ok(Ok(()))) {
tracing::warn!("CLI pipe request failed: {reply:?}");
}
// Dropping after the write lets the client drain its reply before disconnecting.
drop(pipe);
pipe = next;
}
});
})?;
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
}
}
/// Sends `request` to the app listening on `socket` and waits for its answer.
pub use imp::send;
/// Answers requests on `socket` with `handle`, on a thread of its own.
pub use imp::serve;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_names_separate_identifiers_and_stay_short() {
let release = socket_path("ai.l0ng.openidea").unwrap();
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
assert_ne!(release, dev);
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
}
#[test]
fn answers_only_its_protocol_version() {
let ok = |_: Request| Response::Ok;
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
let old = r#"{"version":0,"targets":[]}"#;
assert!(matches!(answer(old, &ok), Response::Error { .. }));
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
}
#[cfg(windows)]
#[test]
fn named_pipe_round_trip() {
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
for _ in 0..2 {
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
}
assert!(serve(&socket, |_| Response::Ok).is_err());
}
#[cfg(unix)]
#[test]
fn request_round_trip_and_stale_socket() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
// A file left by an app that exited without removing it.
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv().unwrap().targets, vec![target]);
// A live server keeps its socket.
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
}
#[cfg(unix)]
#[test]
fn refuses_to_remove_an_unrelated_file() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
std::fs::write(&socket, "keep").unwrap();
assert!(serve(&socket, |_| Response::Ok).is_err());
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
}
}
-3
View File
@@ -11,6 +11,3 @@ license.workspace = true
objc2 = { version = "0.6", default-features = false, features = ["std"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSEnumerator", "NSObject", "NSString", "NSURL", "NSValue"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
+1 -60
View File
@@ -21,12 +21,6 @@
use std::path::PathBuf;
/// Replaces every clipboard flavor with plain text. `owner` is the calling window
/// handle on Windows, ignored elsewhere. Call on the main thread on macOS and Linux.
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
imp::set_text(text, owner)
}
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
const HEADER: usize = 20;
@@ -171,12 +165,8 @@ mod imp {
/// Opens the clipboard as its owner: the list is written in full, so no
/// owning window is needed.
fn open() -> bool {
open_for(ptr::null_mut())
}
fn open_for(owner: Handle) -> bool {
for _ in 0..OPEN_ATTEMPTS {
if unsafe { OpenClipboard(owner) } != 0 {
if unsafe { OpenClipboard(ptr::null_mut()) } != 0 {
return true;
}
std::thread::sleep(OPEN_RETRY);
@@ -240,29 +230,6 @@ mod imp {
}
}
pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
let (memory, _) = global_text(text);
if memory.is_null() {
return Err(std::io::Error::last_os_error());
}
// SAFETY: ownership transfers only on success; otherwise we free the allocation.
unsafe {
if !open_for(owner as Handle) {
GlobalFree(memory);
return Err(std::io::Error::last_os_error());
}
let taken = EmptyClipboard() != 0 && !SetClipboardData(CF_UNICODETEXT, memory).is_null();
let error = std::io::Error::last_os_error();
CloseClipboard();
if taken {
Ok(())
} else {
GlobalFree(memory);
Err(error)
}
}
}
pub fn set_files(paths: &[PathBuf]) -> bool {
let bytes = encode(paths);
if bytes.len() <= HEADER {
@@ -373,19 +340,6 @@ mod imp {
})
}
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
let board = NSPasteboard::generalPasteboard();
board.clearContents();
if board.setString_forType(&NSString::from_str(text), unsafe { NSPasteboardTypeString }) {
Ok(())
} else {
Err(std::io::Error::other("Cannot write clipboard text"))
}
})
}
pub fn set_files(paths: &[PathBuf]) -> bool {
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
autoreleasepool(|_| {
@@ -416,19 +370,6 @@ mod imp {
mod imp {
use std::path::PathBuf;
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
gtk::Clipboard::get(&gtk::gdk::SELECTION_CLIPBOARD).set_text(text);
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let _ = text;
Err(std::io::Error::other("Clipboard text is unavailable on this platform"))
}
}
pub fn has_files() -> bool {
false
}
+1 -1
View File
@@ -22,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
-36
View File
@@ -73,8 +73,6 @@ pub struct EditorAppearanceSettings {
pub whitespace_inner: bool,
pub whitespace_trailing: bool,
pub indent_guides: bool,
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
pub rainbow_indent_guides: bool,
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
@@ -97,7 +95,6 @@ impl Default for EditorAppearanceSettings {
whitespace_inner: true,
whitespace_trailing: true,
indent_guides: true,
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
distraction_free_width: 80,
@@ -169,16 +166,6 @@ impl Default for CompletionSettings {
pub struct AutoImportSettings {
pub add_unambiguous: bool,
pub optimize_on_the_fly: bool,
pub imports_on_paste: ImportsOnPaste,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ImportsOnPaste {
#[default]
Ask,
Always,
Never,
}
/// Editor › General › Code Folding.
@@ -264,7 +251,6 @@ pub struct SmartKeysSettings {
pub tab_out_of_pair: bool,
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub escape_on_paste: bool,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
@@ -282,7 +268,6 @@ impl Default for SmartKeysSettings {
surround_selection: true,
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
escape_on_paste: true,
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
@@ -479,24 +464,3 @@ impl Default for EncodingSettings {
}
}
}
#[cfg(test)]
mod auto_import_tests {
use super::{AutoImportSettings, ImportsOnPaste};
#[test]
fn imports_on_paste_defaults_to_ask_for_existing_settings() {
let settings: AutoImportSettings = serde_json::from_str(r#"{"addUnambiguous":true}"#).unwrap();
assert!(settings.add_unambiguous);
assert_eq!(settings.imports_on_paste, ImportsOnPaste::Ask);
}
#[test]
fn imports_on_paste_modes_round_trip() {
for mode in ["ask", "always", "never"] {
let json = serde_json::json!({ "importsOnPaste": mode });
let settings: AutoImportSettings = serde_json::from_value(json).unwrap();
assert_eq!(serde_json::to_value(settings).unwrap()["importsOnPaste"], mode);
}
}
}
-28
View File
@@ -10,29 +10,11 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum McpUiRequest {
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum IdeEvent {
/// Project data source settings changed; reload the Database tree.
DbSourcesChanged,
McpRequest {
id: u32,
request: McpUiRequest,
},
McpRequestClosed {
id: u32,
},
/// Project window membership or selection changed.
WindowGroupsChanged,
/// A window entered or left full screen, itself or as a member of a full-screen window group.
WindowFullscreenChanged,
ProjectTrustChanged {
root: crate::IpcPath,
trusted: bool,
@@ -42,10 +24,6 @@ pub enum IdeEvent {
created: Vec<crate::IpcPath>,
modified: Vec<crate::IpcPath>,
removed: Vec<crate::IpcPath>,
/// Subtrees whose ignore rules changed, after the matchers were rebuilt.
#[serde(default)]
#[specta(optional)]
ignore_rules: Vec<crate::IpcPath>,
},
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
ExcludedDirsChanged {
@@ -152,12 +130,6 @@ pub enum IdeEvent {
SettingsChanged {
key: String,
},
UpdateStatusChanged {
status: crate::updates::UpdateStatus,
},
UpdateAvailable {
update: crate::updates::UpdateInfo,
},
/// The project's source / test / resource / excluded roots changed (build files
/// changed or folders were marked); the UI re-reads them with `project_roots`.
ProjectRootsChanged {
-124
View File
@@ -1,124 +0,0 @@
//! Go workspace module paths shared by project builds and run detection.
use std::path::PathBuf;
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
pub fn work_modules(text: &str) -> Vec<PathBuf> {
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\n' | '(' | ')' => tokens.push(ch.to_string()),
ch if ch.is_whitespace() => {}
'/' if chars.peek() == Some(&'/') => {
for c in chars.by_ref() {
if c == '\n' {
tokens.push("\n".into());
break;
}
}
}
'"' | '`' => {
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
tokens.push(token);
}
_ => {
let mut token = ch.to_string();
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
token.push(chars.next().unwrap());
}
tokens.push(token);
}
}
}
let mut out = Vec::new();
let mut start = true;
let mut tokens = tokens.into_iter();
while let Some(token) = tokens.next() {
if token == "\n" {
start = true;
continue;
}
if start
&& token == "use"
&& let Some(path) = tokens.next()
{
if path == "(" {
let mut paths = Vec::new();
let mut closed = false;
for path in tokens.by_ref() {
if path == ")" {
closed = true;
break;
}
if path != "\n" && !path.is_empty() {
paths.push(PathBuf::from(path));
}
}
if closed {
out.extend(paths);
}
} else if path != "\n" && !path.is_empty() {
out.push(PathBuf::from(path));
}
}
start = false;
}
let mut seen = std::collections::HashSet::new();
out.retain(|path| seen.insert(path.clone()));
out
}
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
let mut bytes = Vec::new();
while let Some(ch) = chars.next() {
if ch == quote {
return String::from_utf8(bytes).ok();
}
if ch == '\\' && quote == '"' {
let escaped = chars.next()?;
let byte = match escaped {
'a' => 7,
'b' => 8,
'f' => 12,
'n' => 10,
'r' => 13,
't' => 9,
'v' => 11,
'\\' => b'\\',
'"' => b'"',
'x' => {
let digits: String = chars.by_ref().take(2).collect();
if digits.len() != 2 {
return None;
}
u8::from_str_radix(&digits, 16).ok()?
}
'u' | 'U' => {
let width = if escaped == 'u' { 4 } else { 8 };
let digits: String = chars.by_ref().take(width).collect();
if digits.len() != width {
return None;
}
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
bytes.extend_from_slice(ch.to_string().as_bytes());
continue;
}
'0'..='7' => {
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
if digits.len() != 3 {
return None;
}
u8::from_str_radix(&digits, 8).ok()?
}
_ => return None,
};
bytes.push(byte);
} else {
if ch == '\n' && quote == '"' {
return None;
}
bytes.extend_from_slice(ch.to_string().as_bytes());
}
}
None
}
-403
View File
@@ -1,403 +0,0 @@
//! Bounded, ordered process input. Only the worker touches the OS writer.
use std::collections::VecDeque;
use std::io::{self, Write};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::JoinHandle;
#[cfg(any(not(unix), test))]
use std::time::Duration;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WRITES: usize = 1024;
#[cfg(any(not(unix), test))]
const RETRY: Duration = Duration::from_millis(4);
#[derive(Default)]
struct State {
queue: VecDeque<Vec<u8>>,
bytes: usize,
finished: bool,
aborted: bool,
error: Option<String>,
}
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
/// without joining the worker; `finish` instead drains accepted input before EOF.
pub struct InputWriter {
shared: Arc<(Mutex<State>, Condvar)>,
worker: Option<JoinHandle<()>>,
}
impl InputWriter {
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
pub fn new(
name: String,
mut writer: Box<dyn Write + Send>,
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
on_error: impl FnOnce(io::Error) + Send + 'static,
) -> io::Result<Self> {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let worker_shared = shared.clone();
let worker = std::thread::Builder::new().name(name).spawn(move || {
let (lock, wake) = &*worker_shared;
let result = (|| {
loop {
let data = {
let mut state = lock.lock().unwrap();
while state.queue.is_empty() && !state.finished && !state.aborted {
state = wake.wait(state).unwrap();
}
if state.aborted {
return Ok(());
}
let Some(data) = state.queue.pop_front() else { return Ok(()) };
data
};
let mut offset = 0;
loop {
if lock.lock().unwrap().aborted {
return Ok(());
}
let flushing = offset == data.len();
let result = if !flushing {
writer.write(&data[offset..]).and_then(|n| {
if n == 0 {
Err(io::ErrorKind::WriteZero.into())
} else {
offset += n;
Ok(())
}
})
} else {
writer.flush()
};
match result {
Ok(()) if flushing => break,
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
#[cfg(unix)]
match wait_writable() {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
result => result?,
}
#[cfg(not(unix))]
{
let state = lock.lock().unwrap();
if state.aborted {
return Ok(());
}
drop(wake.wait_timeout(state, RETRY).unwrap());
}
}
Err(e) => return Err(e),
}
}
let len = data.len();
drop(data);
let mut state = lock.lock().unwrap();
state.bytes = state.bytes.saturating_sub(len);
}
})();
if let Err(e) = result {
let mut state = lock.lock().unwrap();
if state.aborted {
return;
}
state.error = Some(e.to_string());
state.queue.clear();
state.bytes = 0;
drop(state);
on_error(e);
}
})?;
Ok(Self { shared, worker: Some(worker) })
}
pub fn write(&self, data: &[u8]) -> io::Result<()> {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
if let Some(error) = &state.error {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
}
if state.finished || state.aborted {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
}
if data.is_empty() {
return Ok(());
}
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
));
}
state.bytes += data.len();
state.queue.push_back(data.to_vec());
wake.notify_one();
Ok(())
}
/// Send EOF after the accepted writes, without waiting for the child to read.
pub fn finish(&self) {
let (lock, wake) = &*self.shared;
lock.lock().unwrap().finished = true;
wake.notify_one();
}
pub fn close(&self) {
let (lock, wake) = &*self.shared;
let mut state = lock.lock().unwrap();
state.aborted = true;
state.queue.clear();
wake.notify_one();
}
}
impl Drop for InputWriter {
fn drop(&mut self) {
self.close();
let Some(worker) = self.worker.take() else { return };
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
// A cancellation can race with entry into WriteFile, so retry off
// the caller's thread until the worker observes the closed queue.
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
while !worker.is_finished() {
unsafe {
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
}
std::thread::sleep(RETRY);
}
let _ = worker.join();
});
}
#[cfg(not(windows))]
drop(worker);
}
}
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
/// status flags, so readers must also handle WouldBlock.
#[cfg(unix)]
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
let wait = readiness(fd, libc::POLLOUT)?;
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
return Err(io::Error::last_os_error());
}
Ok(wait)
}
/// Wait for output from a nonblocking PTY without delaying the child's next read.
#[cfg(unix)]
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
readiness(fd, libc::POLLIN)
}
#[cfg(unix)]
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
// Own a duplicate so the hook remains valid if the caller closes its fd.
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
if duplicate == -1 {
return Err(io::Error::last_os_error());
}
// SAFETY: fcntl returned a new, owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
Ok(move || {
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
// Readiness wakes immediately; the timeout only bounds abort latency.
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
return Err(io::Error::last_os_error());
}
if pollfd.revents & libc::POLLNVAL != 0 {
return Err(io::Error::from_raw_os_error(libc::EBADF));
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
#[cfg(unix)]
use std::time::Instant;
struct SlowWriter {
ready: Arc<AtomicBool>,
output: Arc<Mutex<Vec<u8>>>,
dropped: mpsc::Sender<()>,
}
impl Write for SlowWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if !self.ready.load(Ordering::Acquire) {
return Err(io::ErrorKind::WouldBlock.into());
}
let n = bytes.len().min(7);
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Drop for SlowWriter {
fn drop(&mut self) {
let _ = self.dropped.send(());
}
}
#[test]
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
std::thread::sleep(RETRY * 2);
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
writer.close();
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
assert!(output.lock().unwrap().is_empty());
assert!(writer.write(b"closed").is_err());
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
}
#[test]
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
let ready = Arc::new(AtomicBool::new(false));
let output = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel();
let writer = InputWriter::new(
"test-input".into(),
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
#[cfg(unix)]
|| {
std::thread::sleep(RETRY);
Ok(())
},
|e| panic!("{e}"),
)
.unwrap();
let paste = "abé".repeat(1024);
writer.write(paste.as_bytes()).unwrap();
writer.write(b"tail").unwrap();
writer.finish();
assert!(writer.write(b"after EOF").is_err());
ready.store(true, Ordering::Release);
rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
}
#[cfg(unix)]
#[test]
fn readiness_errors_are_reported_and_reject_following_writes() {
let (dropped, _rx) = mpsc::channel();
let (errors, error) = mpsc::channel();
let writer = InputWriter::new(
"test-error-input".into(),
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
move |e| errors.send(e.raw_os_error()).unwrap(),
)
.unwrap();
writer.write(b"input").unwrap();
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
let state = writer.shared.0.lock().unwrap();
assert!(state.queue.is_empty());
assert_eq!(state.bytes, 0);
}
#[test]
fn tiny_writes_are_also_bounded() {
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
let writer = InputWriter { shared, worker: None };
for _ in 0..MAX_WRITES {
writer.write(b"x").unwrap();
}
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
}
#[cfg(unix)]
#[test]
fn writable_input_resumes_without_timer_delays() {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, mut peer) = UnixStream::pair().unwrap();
let capacity: libc::c_int = 1024;
// Keep the OS buffer small enough that a timer per buffer takes seconds.
assert_eq!(
unsafe {
libc::setsockopt(
pipe.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_SNDBUF,
(&capacity as *const libc::c_int).cast(),
std::mem::size_of_val(&capacity) as libc::socklen_t,
)
},
0
);
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let writer =
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
let bytes = vec![b'x'; MAX_BYTES];
writer.write(&bytes).unwrap();
writer.finish();
// Start with a full buffer, then make the fd writable from another thread.
std::thread::sleep(Duration::from_millis(50));
let (tx, rx) = mpsc::channel();
let started = Instant::now();
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
peer.read_to_end(&mut output).unwrap();
tx.send(output).unwrap();
});
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
assert_eq!(output, bytes);
reader.join().unwrap();
eprintln!("4 MiB input drained in {:?}", started.elapsed());
}
#[cfg(unix)]
#[test]
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixStream;
let (pipe, _peer) = UnixStream::pair().unwrap();
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
let mut writer =
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
std::thread::sleep(Duration::from_millis(50));
assert!(!writer.worker.as_ref().unwrap().is_finished());
let started = Instant::now();
writer.close();
writer.worker.take().unwrap().join().unwrap();
assert!(started.elapsed() < Duration::from_millis(250));
}
}
-4
View File
@@ -7,16 +7,13 @@
pub mod editor_settings;
pub mod error;
pub mod event;
pub mod go;
pub mod ids;
pub mod ignored;
pub mod input;
pub mod inspection_settings;
pub mod ipc_path;
pub mod java_runtime;
pub mod jsonc;
pub mod output_encoding;
pub mod platform;
pub mod process;
pub mod project_dir;
pub mod project_roots;
@@ -26,7 +23,6 @@ pub mod settings;
pub mod task;
pub mod text;
pub mod tool_path;
pub mod updates;
pub mod version_managers;
pub mod workflow_settings;
pub mod wsl;
-29
View File
@@ -1,29 +0,0 @@
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
#[cfg(windows)]
pub fn windows_build_number() -> Option<u32> {
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut info =
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
// SAFETY: `info` is initialized and declares the size of the writable structure.
let status = unsafe { RtlGetVersion(&mut info) };
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
}
#[cfg(not(windows))]
pub fn windows_build_number() -> Option<u32> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn windows_build_is_platform_specific() {
let build = super::windows_build_number();
#[cfg(windows)]
assert!(build.is_some_and(|build| build > 0));
#[cfg(not(windows))]
assert_eq!(build, None);
}
}
+8 -310
View File
@@ -104,23 +104,12 @@ fn signal_group(child: &Child, signal: libc::c_int) {
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
#[cfg(windows)]
pub use windows_job::Job;
#[cfg(windows)]
pub use windows_tree::ProcessTree;
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
// When the child started nothing, killing it alone is the whole job:
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
// same outcome, and the callers kill the child themselves.
if started.is_empty() {
return;
}
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
@@ -141,48 +130,13 @@ mod windows_tree {
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
TerminateProcess, WaitForSingleObject,
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
pub struct ProcessTree {
processes: Vec<OwnedHandle>,
}
impl ProcessTree {
pub fn capture(pid: u32) -> Self {
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
let children = descendants_with(pid, PROCESS_TERMINATE);
Self { processes: std::iter::once(root).chain(children).collect() }
}
pub fn is_running(&self) -> bool {
self.processes.iter().any(|process| {
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
unsafe {
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
}
})
}
/// Requests termination children first; poll `is_running` until termination completes.
pub fn terminate(&self) {
for process in self.processes.iter().rev() {
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
}
}
}
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
descendants_with(root, 0)
}
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
@@ -193,7 +147,7 @@ mod windows_tree {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open_with(pid, access) else { continue };
let Some(handle) = open(pid) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
@@ -233,12 +187,8 @@ mod windows_tree {
}
fn open(pid: u32) -> Option<OwnedHandle> {
open_with(pid, 0)
}
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
@@ -253,167 +203,6 @@ mod windows_tree {
}
}
/// A kernel job object over one launched process tree. Everything a launch starts joins
/// the job by inheritance, so [`Job::terminate`] ends the whole tree — also processes
/// started after the launch's snapshot was taken, orphans of a launcher that already
/// exited (where a `taskkill /T /F` from the tracked pid no longer finds them), and
/// processes born in the window a process-enumeration kill would miss. Spawns assign
/// their root right after it starts; when the assignment fails (an enclosing job that
/// disallows nesting, an already-exited pid), callers fall back to `taskkill /T /F`.
#[cfg(windows)]
mod windows_job {
use std::mem::size_of;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_BASIC_PROCESS_ID_LIST, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectBasicProcessIdList, JobObjectExtendedLimitInformation, QueryInformationJobObject,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{IO_COUNTERS, OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A job object holding one launch's processes. The handle is kept open for the
/// launch's lifetime: `KILL_ON_JOB_CLOSE` ends anything still in the job when the
/// launch record (a run entry, a debug session) is finally dropped.
pub struct Job(OwnedHandle);
impl Job {
/// Takes the just-started `child` into a new job, so everything it starts is in
/// there too. `None` when the job could not be made or the process rejected the
/// assignment (callers fall back to `taskkill /T /F` from the child's pid).
pub fn for_child(child: &std::process::Child) -> Option<Job> {
// SAFETY: the child's own handle is a live process handle.
Self::from_handle(child.as_raw_handle())
}
/// [`Job::for_child`] for a process known by its id (a PTY child, a follow-up run,
/// a `tokio` child). `None` when the id no longer names an openable process — a
/// caller that holds the child cannot have had it reaped, so this is a dead or
/// already-gone launch.
pub fn for_pid(pid: u32) -> Option<Job> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
// Assignment needs `SET_QUOTA | TERMINATE` on the process.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return None;
}
// SAFETY: the handle was just opened and is owned here.
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
Self::from_handle(handle.as_raw_handle())
}
fn from_handle(handle: RawHandle) -> Option<Job> {
// SAFETY: plain calls; a non-null result is a job handle owned by the caller.
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() || job == INVALID_HANDLE_VALUE {
return None;
}
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let job = unsafe { OwnedHandle::from_raw_handle(job) };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
PerProcessUserTimeLimit: 0,
PerJobUserTimeLimit: 0,
LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
MinimumWorkingSetSize: 0,
MaximumWorkingSetSize: 0,
ActiveProcessLimit: 0,
Affinity: 0,
PriorityClass: 0,
SchedulingClass: 0,
},
IoInfo: IO_COUNTERS {
ReadOperationCount: 0,
WriteOperationCount: 0,
OtherOperationCount: 0,
ReadTransferCount: 0,
WriteTransferCount: 0,
OtherTransferCount: 0,
},
ProcessMemoryLimit: 0,
JobMemoryLimit: 0,
PeakProcessMemoryUsed: 0,
PeakJobMemoryUsed: 0,
};
// SAFETY: the job handle is owned here and `limits` is sized as the API requires.
let ok = unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
&mut limits as *mut _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
return None;
}
// SAFETY: the process handle belongs to the just-spawned child and outlives
// this call; the job handle is owned here.
let ok = unsafe { AssignProcessToJobObject(job.as_raw_handle(), handle) };
if ok == 0 {
return None;
}
Some(Job(job))
}
/// Adds another just-started root of the same launch to the job (a Go build's
/// follow-up run, a JVM a debug session starts itself). `false` when it did not
/// take; the process then relies on its own kill.
pub fn assign_pid(&self, pid: u32) -> bool {
// SAFETY: plain call; the handle is opened only to assign it, and the job
// handle is owned and valid.
let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if handle.is_null() {
return false;
}
// SAFETY: both handles are valid for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.0.as_raw_handle(), handle) };
// SAFETY: the process handle was opened above and is owned here.
unsafe { drop(OwnedHandle::from_raw_handle(handle)) };
ok != 0
}
/// Terminates every process in the job. `false` when the call failed (the job
/// handle is invalid): callers then fall back to `taskkill /T /F`. Termination is
/// asynchronous; poll [`Job::is_running`] (bounded by [`TREE_EXIT_WAIT`]) until
/// the tree is gone.
pub fn terminate(&self) -> bool {
// SAFETY: a job handle owned here.
unsafe { TerminateJobObject(self.0.as_raw_handle(), 1) != 0 }
}
/// Whether any process of the launch is still in the job. When the job's state
/// cannot be read, reports gone: waits on this must not wedge, and a kill via
/// [`Job::terminate`] does not depend on the answer.
pub fn is_running(&self) -> bool {
// A list of one is the common case; `ERROR_MORE_DATA` says the real list is
// longer, which means there are processes.
let mut list = JOBOBJECT_BASIC_PROCESS_ID_LIST {
NumberOfAssignedProcesses: 0,
NumberOfProcessIdsInList: 0,
ProcessIdList: [0],
};
let mut returned = 0u32;
// SAFETY: the job handle is owned here and `list` is sized as the API requires.
let ok = unsafe {
QueryInformationJobObject(
self.0.as_raw_handle(),
JobObjectBasicProcessIdList,
&mut list as *mut _ as *mut _,
size_of::<JOBOBJECT_BASIC_PROCESS_ID_LIST>() as u32,
&mut returned,
)
};
if ok != 0 {
list.NumberOfProcessIdsInList > 0
} else {
std::io::Error::last_os_error().raw_os_error() == Some(ERROR_MORE_DATA as i32)
}
}
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
@@ -510,11 +299,9 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
/// What to change in `vars` (an environment) so a child sees the host's environment rather
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
/// variables left empty are removed, and so are the runtime's own launch variables.
/// Nothing changes when `APPDIR` is not set.
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
let Some(appdir) =
@@ -522,12 +309,7 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
else {
return Vec::new();
};
let inside = |entry: &str| {
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
// another image's (e.g. the pre-update one a restart inherited), never the host's.
own || entry.starts_with("/tmp/.mount_")
};
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
let mut fixes = Vec::new();
for (key, value) in vars {
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
@@ -933,7 +715,7 @@ mod tests {
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
("HOME", "/home/me"),
("JAVA_HOME", "/opt/jdk"),
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
]);
assert_eq!(
got,
@@ -946,27 +728,6 @@ mod tests {
);
}
#[test]
fn entries_of_a_previous_updates_mount_are_dropped() {
// An in-app update restart inherits the old mount's entries: a child's curl
// must not load the old image's libraries (they can still be mounted).
let got = fixes(&[
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
]);
assert_eq!(
got,
[
fix("APPDIR", None),
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
fix("PATH", Some("/usr/bin")),
fix("GIO_MODULE_DIR", None),
]
);
}
#[test]
fn variables_left_empty_are_removed() {
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
@@ -1119,66 +880,3 @@ mod kill_tests {
assert!(started.elapsed() < Duration::from_secs(5));
}
}
#[cfg(all(test, windows))]
mod job_tests {
use super::*;
use std::os::windows::process::CommandExt;
use std::process::Stdio;
use std::time::Instant;
/// `cmd` that keeps a `ping` grandchild for a minute, like a launcher that started an
/// application: the job must reach both.
fn launch_tree() -> (std::process::Child, Job, ProcessTree) {
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let child = cmd.spawn().unwrap();
let job = Job::for_child(&child).expect("the job takes the launch");
let tree = ProcessTree::capture(child.id());
assert!(tree.is_running(), "cmd started a grandchild the job inherits");
(child, job, tree)
}
/// Termination is asynchronous: the job reports its processes gone while their
/// handles still wait, so wait on the handles (the reused-pid-proof oracle).
fn assert_gone(tree: &ProcessTree) {
let until = Instant::now() + Duration::from_secs(10);
while tree.is_running() {
assert!(Instant::now() < until, "the tree survived the job");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn job_terminates_the_tree_the_root_started() {
let (mut child, job, tree) = launch_tree();
assert!(job.is_running());
assert!(job.terminate(), "TerminateJobObject succeeds");
// The captured tree's handles survive the root's death, so the wait below is the
// grandchild's, not a reused pid's.
assert_gone(&tree);
assert!(!job.is_running());
let _ = child.wait();
}
#[test]
fn a_later_spawned_root_joins_the_job() {
let (mut first, job, _) = launch_tree();
let mut cmd = Command::new("cmd");
cmd.args(["/D", "/S", "/C", "ping -n 60 127.0.0.1 >NUL"]).stdin(Stdio::null()).creation_flags(CREATE_NO_WINDOW);
let mut second = cmd.spawn().unwrap();
let second_tree = ProcessTree::capture(second.id());
assert!(job.assign_pid(second.id()), "the follow-up run joins the job");
assert!(job.terminate());
assert_gone(&second_tree);
let _ = first.wait();
let _ = second.wait();
}
#[test]
fn a_dead_pid_makes_no_job() {
// An id of a process this test cannot open (or that does not exist) gives no job,
// which is the callers' cue for the `taskkill /T /F` fallback.
assert!(Job::for_pid(u32::MAX).is_none());
}
}
-78
View File
@@ -1,6 +1,5 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
@@ -36,23 +35,10 @@ pub fn discover_matching(
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
discover_matching_cancellable(root, max_depth, stop_at_match, &crate::CancellationToken::new(), marked)
}
pub fn discover_matching_cancellable(
root: &Path,
max_depth: usize,
stop_at_match: bool,
cancel: &crate::CancellationToken,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if cancel.is_cancelled() {
break;
}
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
@@ -80,70 +66,6 @@ pub fn discover_matching_cancellable(
found
}
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
pub fn expand_members(root: &Path, patterns: &[String], marker: &str) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for pattern in patterns {
let pattern = pattern.trim().trim_start_matches("./").trim_end_matches('/');
if pattern.is_empty() || pattern.starts_with('!') {
continue;
}
let base = pattern.strip_suffix("/**").or_else(|| pattern.strip_suffix("/*"));
match base {
Some(base) if !base.contains('*') => {
dirs.extend(children(&root.join(base)).into_iter().filter(|d| d.join(marker).is_file()))
}
None if !pattern.contains('*') => {
let dir = root.join(pattern);
if dir.join(marker).is_file() {
dirs.push(dir);
}
}
_ => {}
}
}
let mut seen = HashSet::new();
dirs.retain(|d| seen.insert(d.clone()));
dirs
}
pub fn node_workspace_members(root: &Path) -> Vec<PathBuf> {
let package = std::fs::read(root.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or_default();
expand_members(root, &node_workspace_patterns(root, &package), "package.json")
}
fn node_workspace_patterns(root: &Path, pkg: &serde_json::Value) -> Vec<String> {
let mut patterns: Vec<String> = Vec::new();
let ws = pkg.get("workspaces");
let list = ws
.and_then(serde_json::Value::as_array)
.or_else(|| ws.and_then(|w| w.get("packages")).and_then(serde_json::Value::as_array));
if let Some(list) = list {
patterns.extend(list.iter().filter_map(serde_json::Value::as_str).map(str::to_owned));
}
if let Ok(yaml) = std::fs::read_to_string(root.join("pnpm-workspace.yaml")) {
let mut in_packages = false;
for line in yaml.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if !line.starts_with([' ', '\t', '-']) {
in_packages = trimmed.starts_with("packages:");
continue;
}
if in_packages && let Some(item) = trimmed.strip_prefix('-') {
let item = item.split(" #").next().unwrap_or_default().trim().trim_matches(['"', '\'']);
patterns.push(item.to_owned());
}
}
}
patterns
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
-24
View File
@@ -191,30 +191,6 @@ pub struct RunTarget {
pub image_user: bool,
}
/// A plugin-owned argv wrapper for project runs (API 1.4).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunTarget {
pub id: String,
pub name: String,
pub program: String,
pub args: Vec<String>,
pub workdir_args: Vec<String>,
pub container_root: Option<String>,
pub commands: Vec<PluginCommandOverride>,
}
/// Per-command argv prefix and optional replacement working-directory arguments.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginCommandOverride {
pub name: String,
pub args: Vec<String>,
/// None inherits the target's list; Some([]) suppresses it.
#[serde(default)]
pub workdir_args: Option<Vec<String>>,
}
#[cfg(test)]
mod deployment_tests {
use super::*;
+3 -573
View File
@@ -59,16 +59,14 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
pub cmake: CMakeSettings,
/// Settings › Agents.
/// Tools › Agents › Chat.
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// 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,189 +78,14 @@ pub struct AgentSettings {
pub show_usage_limits: bool,
/// What the session has cost so far, as the agent reports it.
pub show_cost: bool,
/// A finished turn's work folds into one line under its message, leaving the reply.
pub fold_turns: bool,
/// Streaming text is revealed at a steady pace rather than chunk by chunk.
pub smooth_text: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = ThinkingDisplay)]
pub thinking: ThinkingDisplay,
#[serde(deserialize_with = "or_default")]
#[specta(type = ToolCallDisplay)]
pub tool_calls: ToolCallDisplay,
/// A tool call's output (a command's, a diff) is shown without opening it.
pub expand_tool_output: bool,
#[serde(deserialize_with = "or_default")]
#[specta(type = SendKey)]
pub send_with: SendKey,
/// What sending does while the agent works: queue the message for after the turn, or
/// send it into the turn.
#[serde(deserialize_with = "or_default")]
#[specta(type = WhileRunning)]
pub while_running: WhileRunning,
/// Settings new tasks of an agent start with: agent id → option id → value.
#[serde(deserialize_with = "agent_defaults")]
#[specta(type = BTreeMap<String, BTreeMap<String, String>>)]
pub defaults: BTreeMap<String, BTreeMap<String, String>>,
/// MCP servers passed to coding agents in `session/new`, `session/load` and `session/fork`,
/// keyed by server name (`[agents.mcpServers.<name>]`).
#[serde(deserialize_with = "or_default")]
#[specta(type = BTreeMap<String, AgentMcpServer>)]
pub mcp_servers: BTreeMap<String, AgentMcpServer>,
}
impl Default for AgentSettings {
fn default() -> Self {
Self {
show_context_usage: true,
show_work_time: true,
show_usage_limits: true,
show_cost: false,
fold_turns: true,
smooth_text: true,
thinking: ThinkingDisplay::Auto,
tool_calls: ToolCallDisplay::Grouped,
expand_tool_output: false,
send_with: SendKey::Enter,
while_running: WhileRunning::Queue,
defaults: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
}
Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false }
}
}
/// Transport of an MCP server configured in Settings › Agents › MCP Servers.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport {
#[default]
Stdio,
Http,
Sse,
}
/// An MCP server passed to coding agents in `session/new`, `session/load` and `session/fork`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentMcpServer {
#[serde(deserialize_with = "or_default")]
#[specta(type = McpTransport)]
pub transport: McpTransport,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
/// Agent provider ids this server is enabled for (e.g. `["claude", "codex"]`);
/// empty means enabled for all agents.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<String>,
}
impl Default for AgentMcpServer {
fn default() -> Self {
Self {
transport: McpTransport::Stdio,
enabled: true,
command: None,
args: Vec::new(),
env: BTreeMap::new(),
url: None,
headers: BTreeMap::new(),
agents: Vec::new(),
}
}
}
/// A hand-edited value these settings don't know reads as the default, rather than
/// failing the whole settings file.
fn or_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
Ok(serde_json::from_value(serde_json::Value::deserialize(d)?).unwrap_or_default())
}
/// [`AgentSettings::defaults`], keeping only the text values of each agent's table.
fn agent_defaults<'de, D>(d: D) -> Result<BTreeMap<String, BTreeMap<String, String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let serde_json::Value::Object(agents) = serde_json::Value::deserialize(d)? else { return Ok(BTreeMap::new()) };
Ok(agents
.into_iter()
.filter_map(|(agent, options)| match options {
serde_json::Value::Object(options) => Some((
agent,
options
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(v) => Some((k, v)),
_ => None,
})
.collect(),
)),
_ => None,
})
.collect())
}
/// How the agent's reasoning shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThinkingDisplay {
/// Open while it streams, folded once it ends.
#[default]
Auto,
Expanded,
Collapsed,
}
/// How tool calls between messages show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ToolCallDisplay {
/// A run of calls is one line saying what they did ("Read 4 files · Edited 2 files").
#[default]
Grouped,
/// Each call on its own line.
Each,
}
/// The key that sends a message; the other one starts a new line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum SendKey {
#[default]
Enter,
ModEnter,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WhileRunning {
#[default]
Queue,
Steer,
}
/// Agents › MCP Server: agents use the IDE's built-in tools.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
}
/// Absent profiles retain automatic discovery for existing projects.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -407,7 +230,6 @@ impl Default for Settings {
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
plugins: BTreeMap::new(),
}
}
@@ -433,10 +255,6 @@ pub struct CustomKeymap {
pub struct GeneralSettings {
pub reopen_last_project: bool,
pub confirm_exit: bool,
/// When on, the last window closes instead of showing the Welcome screen.
pub close_without_welcome: bool,
/// When on, closing a window drops its saved editor tabs, so they are not restored next time.
pub close_tabs_with_window: bool,
/// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`.
pub open_project_in: String,
/// Initial folder of the Open / Clone dialogs; empty = the OS default.
@@ -452,7 +270,6 @@ pub struct GeneralSettings {
pub project_name: String,
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
pub update_source: String,
pub auto_check_updates: bool,
}
impl Default for GeneralSettings {
@@ -460,8 +277,6 @@ impl Default for GeneralSettings {
Self {
reopen_last_project: true,
confirm_exit: true,
close_without_welcome: false,
close_tabs_with_window: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -470,7 +285,6 @@ impl Default for GeneralSettings {
power_save_mode: false,
project_name: String::new(),
update_source: "auto".into(),
auto_check_updates: true,
}
}
}
@@ -525,21 +339,6 @@ impl Default for NotificationGroupSettings {
}
}
/// Where the Navigation Bar (the file path breadcrumbs) is shown. Mirrors
/// IDEA's `View | Appearance | Navigation Bar` (Top / Status Bar / Don't show).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum NavigationBarPosition {
/// Above the editor, below the main menu / toolbar.
Top,
/// In the footer, next to the status bar widgets — the default, matching
/// where the bar lived before the three-state choice existed.
#[default]
StatusBar,
/// Not shown at all.
Hidden,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AppearanceSettings {
@@ -576,8 +375,6 @@ pub struct AppearanceSettings {
pub menu_icons: bool,
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
pub main_menu_style: String,
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
pub main_menu_alt: bool,
/// `subpixel` | `greyscale` | `none`
pub ide_antialiasing: String,
pub editor_antialiasing: String,
@@ -585,12 +382,6 @@ pub struct AppearanceSettings {
pub show_tool_window_names: bool,
pub show_tool_window_badges: bool,
pub widescreen_tool_windows: bool,
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
pub show_main_toolbar: bool,
pub show_status_bar: bool,
/// View › Appearance › Navigation Bar: the file path breadcrumbs, shown in
/// the status bar by default, above the editor, or not at all.
pub navigation_bar: NavigationBarPosition,
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
/// (status bar context menu); widgets not listed use their default.
pub status_bar_widgets: BTreeMap<String, bool>,
@@ -654,16 +445,12 @@ impl Default for AppearanceSettings {
dnd_with_alt_only: false,
menu_icons: true,
main_menu_style: "hamburger".into(),
main_menu_alt: true,
ide_antialiasing: "greyscale".into(),
editor_antialiasing: "greyscale".into(),
show_tool_window_bars: true,
show_tool_window_names: false,
show_tool_window_badges: true,
widescreen_tool_windows: false,
show_main_toolbar: true,
show_status_bar: true,
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
}
@@ -701,9 +488,6 @@ pub struct EditorSettings {
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
/// parameters get a colour derived from their name (IDEA's rainbow identifiers).
pub semantic_highlighting: bool,
/// Docstring markup format whose tags and keywords the Python editor highlights
/// (`plain`, `rest`, `epytext`, `google`, `numpy`).
pub docstring_format: String,
/// Color Scheme › Language Defaults › Rainbow brackets: `()`, `[]`, `{}` coloured by depth.
pub rainbow_brackets: bool,
pub ligatures: bool,
@@ -748,7 +532,6 @@ impl Default for EditorSettings {
color_overrides: BTreeMap::new(),
custom_schemes: BTreeMap::new(),
semantic_highlighting: false,
docstring_format: "rest".into(),
rainbow_brackets: false,
ligatures: false,
fallback_font: String::new(),
@@ -793,15 +576,6 @@ pub struct FileSettings {
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposeFileOverride {
Kept,
Excluded,
}
/// One language's file name and hashbang patterns on top of its definition.
@@ -829,7 +603,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
compose_file_overrides: BTreeMap::new(),
}
}
}
@@ -837,8 +610,6 @@ impl Default for FileSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
pub plugin_formatters: BTreeMap<String, String>,
pub python: PythonSettings,
pub node: NodeSettings,
pub php: PhpSettings,
@@ -851,13 +622,11 @@ pub struct LanguagesSettings {
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
pub arduino: ArduinoSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
plugin_formatters: BTreeMap::new(),
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
@@ -873,22 +642,10 @@ impl Default for LanguagesSettings {
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
arduino: ArduinoSettings::default(),
}
}
}
/// Arduino tool discovery paths and default board FQBN, configured in `settings.toml`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArduinoSettings {
pub cli: String,
pub clangd: String,
pub language_server: String,
pub cli_config: String,
pub fqbn: String,
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -991,8 +748,6 @@ impl Default for PythonSettings {
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
@@ -1020,8 +775,6 @@ pub struct RubySettings {
pub struct NodeSettings {
/// `node` executable; empty = the one on `PATH`.
pub interpreter: String,
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
pub run_target: String,
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
pub package_manager: String,
}
@@ -1097,8 +850,6 @@ pub struct JavaSettings {
/// Project JDK home; empty = per module, the installed JDK matching the
/// Java version it targets.
pub jdk: String,
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
pub language_level: String,
/// JDK home the Java language server (jdtls) runs on, apart from the project
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
/// else the first installed one that is.
@@ -1106,8 +857,6 @@ pub struct JavaSettings {
/// Extra JVM options for the Java language server (jdtls), space-separated
/// (VS Code's `java.jdt.ls.vmargs`).
pub jdtls_vm_args: String,
/// Share one JDT LS across builds in an ordinary project window; other windows remain separate.
pub jdtls_shared: bool,
/// Build Tools › Maven.
pub maven: MavenSettings,
/// Build Tools › Gradle.
@@ -1154,8 +903,6 @@ pub enum GradleDistribution {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct MavenSettings {
/// JDK home used to run Maven; empty = Project JDK, else the POM's Java version.
pub runner_jre: String,
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
@@ -1164,8 +911,6 @@ pub struct MavenSettings {
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
pub toolchains: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
@@ -1177,25 +922,9 @@ pub struct MavenSettings {
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
pub threads: String,
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
pub active_profiles: String,
}
impl MavenSettings {
/// Settings profiles followed by the Maven tool window's choices.
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
let mut profiles = Vec::new();
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
profiles.push(profile.to_owned());
}
}
let excluded: Vec<String> =
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
profiles.retain(|p| !excluded.contains(p));
profiles
}
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
@@ -1224,98 +953,6 @@ impl Default for TypeScriptSettings {
}
}
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyScope {
#[default]
Compile,
Test,
Runtime,
Provided,
}
/// A project-level library (Project Structure › Libraries).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectLibrary {
pub name: String,
/// Class archives (`.jar`) or class directories.
pub classes: Vec<String>,
/// Source archives (`-sources.jar`, `.zip`) or source directories.
pub sources: Vec<String>,
/// Documentation archives (`-javadoc.jar`) or directories.
pub javadocs: Vec<String>,
pub scope: DependencyScope,
}
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleDependency {
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
pub kind: String,
pub target: String,
pub scope: DependencyScope,
pub exported: bool,
}
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ModuleStructureSettings {
/// Module SDK home override; empty = inherit Project SDK.
pub jdk: String,
/// Module language level override; empty = inherit Project language level.
pub language_level: String,
/// Module compiler output directory override; empty = inherit Project compiler output.
pub compiler_output: String,
/// Module test compiler output directory override; empty = default.
pub test_compiler_output: String,
/// Additional module dependencies.
pub dependencies: Vec<ModuleDependency>,
}
/// Framework facet attached to a module (Project Structure › Facets).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FacetConfig {
/// `"web"` | `"spring"` | `"jpa"`.
pub kind: String,
/// Module relative path (`"."` for root module).
pub module: String,
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
pub descriptor: String,
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
pub web_root: String,
}
/// Packaging type of a Project Structure artifact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ArtifactKind {
#[default]
Jar,
War,
WarExploded,
}
/// Packaging artifact definition (Project Structure › Artifacts).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ArtifactConfig {
pub name: String,
pub kind: ArtifactKind,
/// Module relative path (`"."` for root module).
pub module: String,
/// Output directory (relative to project root or absolute).
pub output_directory: String,
/// Main class for executable JAR artifacts.
pub main_class: String,
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
pub include_libraries: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ProjectStructureSettings {
@@ -1330,35 +967,11 @@ pub struct ProjectStructureSettings {
pub unmarked: Vec<String>,
/// Content roots besides the project folder (absolute paths), shown and searched with it.
pub content_roots: Vec<String>,
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
pub dependencies: Vec<String>,
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
/// A member not listed here (or with an empty list) navigates only into itself.
pub member_dependencies: BTreeMap<String, Vec<String>>,
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
pub exclude_files: String,
/// Project compiler output directory (relative to project root or absolute; empty = default).
pub compiler_output: String,
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
pub modules: BTreeMap<String, ModuleStructureSettings>,
/// Project-level libraries (`JAR`s / class folders / source archives).
pub libraries: Vec<ProjectLibrary>,
/// Module framework facets (`web`, `spring`, `jpa`).
pub facets: Vec<FacetConfig>,
/// Packaging artifacts (`jar`, `war`, `warExploded`).
pub artifacts: Vec<ArtifactConfig>,
}
impl ProjectStructureSettings {
/// Overrides belong to the module's full path, never just its directory name.
pub fn module_settings(
&self,
root: &std::path::Path,
module: &std::path::Path,
) -> Option<&ModuleStructureSettings> {
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
}
/// The "Exclude files" name patterns.
pub fn exclude_file_patterns(&self) -> Vec<String> {
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
@@ -1396,26 +1009,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn window_close_settings_default_off_and_round_trip() {
for old in [r#"{}"#, r#"{"general":{"confirmExit":false}}"#] {
let settings: Settings = serde_json::from_str(old).unwrap();
assert!(!settings.general.close_without_welcome);
assert!(!settings.general.close_tabs_with_window);
}
for close_window in [false, true] {
for close_tabs in [false, true] {
let mut settings = Settings::default();
settings.general.close_without_welcome = close_window;
settings.general.close_tabs_with_window = close_tabs;
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["general"]["closeWithoutWelcome"], close_window);
assert_eq!(json["general"]["closeTabsWithWindow"], close_tabs);
assert_eq!(serde_json::from_value::<Settings>(json).unwrap().general, settings.general);
}
}
}
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
@@ -1478,21 +1071,6 @@ mod tests {
}
}
#[test]
fn docstring_format_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"editor":{"semanticHighlighting":true}}"#).unwrap();
assert_eq!(old.editor.docstring_format, "rest");
assert_eq!(Settings::default().editor.docstring_format, "rest");
for format in ["plain", "rest", "epytext", "google", "numpy"] {
let mut settings = Settings::default();
settings.editor.docstring_format = format.into();
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["editor"]["docstringFormat"], format);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.editor.docstring_format, format);
}
}
#[test]
fn tool_window_badges_default_on_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
@@ -1508,27 +1086,6 @@ mod tests {
}
}
#[test]
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
// accepts a key path it finds there.
let json = serde_json::to_value(Settings::default()).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
for on in [true, false] {
let mut settings = Settings::default();
settings.vcs.commit.double_click_opens_diff = Some(on);
let json = serde_json::to_value(&settings).unwrap();
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
let restored: Settings = serde_json::from_value(json).unwrap();
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
}
// Explicit `null` reads back as unset.
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
}
#[test]
fn agent_meters_default_and_round_trip() {
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
@@ -1543,33 +1100,13 @@ mod tests {
assert_eq!(restored.agents, settings.agents);
}
#[test]
fn hand_edited_agent_values_it_does_not_know_keep_the_rest() {
let settings: Settings = serde_json::from_str(
r#"{"editor":{"fontSize":17},"agents":{"thinking":"open","sendWith":"modEnter",
"defaults":{"claude":{"model":"opus","effort":3},"codex":"x"}}}"#,
)
.unwrap();
assert_eq!(settings.editor.font_size, 17.0);
assert_eq!(settings.agents.thinking, ThinkingDisplay::Auto);
assert_eq!(settings.agents.send_with, SendKey::ModEnter);
assert_eq!(
settings.agents.defaults,
BTreeMap::from([("claude".into(), BTreeMap::from([("model".into(), "opus".into())]))])
);
}
#[test]
fn main_menu_style_defaults_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
assert_eq!(old.appearance.main_menu_style, "hamburger");
assert!(old.appearance.main_menu_alt);
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
assert_eq!(merged.appearance.main_menu_style, "merged");
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
assert!(!off.appearance.main_menu_alt);
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
}
#[test]
@@ -1615,81 +1152,12 @@ mod tests {
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
assert!(Settings::default().files.file_types.is_empty());
}
#[test]
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
assert!(old.project_structure.compiler_output.is_empty());
assert!(old.project_structure.modules.is_empty());
assert!(old.project_structure.libraries.is_empty());
assert!(old.project_structure.facets.is_empty());
assert!(old.project_structure.artifacts.is_empty());
assert!(old.languages.java.language_level.is_empty());
let mut settings = Settings::default();
settings.languages.java.language_level = "21".into();
settings.project_structure.compiler_output = "out/production".into();
settings.project_structure.modules.insert(
"backend".into(),
ModuleStructureSettings {
jdk: "/opt/jdk-21".into(),
language_level: "21".into(),
compiler_output: "backend/target/classes".into(),
test_compiler_output: "backend/target/test-classes".into(),
dependencies: vec![ModuleDependency {
kind: "library".into(),
target: "custom-lib".into(),
scope: DependencyScope::Compile,
exported: true,
}],
},
);
settings.project_structure.libraries.push(ProjectLibrary {
name: "custom-lib".into(),
classes: vec!["lib/custom.jar".into()],
sources: vec!["lib/custom-sources.jar".into()],
javadocs: Vec::new(),
scope: DependencyScope::Compile,
});
settings.project_structure.facets.push(FacetConfig {
kind: "web".into(),
module: "backend".into(),
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
web_root: "src/main/webapp".into(),
});
settings.project_structure.artifacts.push(ArtifactConfig {
name: "backend:war exploded".into(),
kind: ArtifactKind::WarExploded,
module: "backend".into(),
output_directory: "out/artifacts/backend_war_exploded".into(),
main_class: String::new(),
include_libraries: vec!["custom-lib".into()],
});
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
let restored: Settings = serde_json::from_value(value).unwrap();
assert_eq!(restored, settings);
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
#[test]
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn maven_linkage_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
@@ -1736,24 +1204,6 @@ mod maven_linkage_tests {
assert_eq!(threads("2..5C").as_deref(), None);
}
#[test]
fn maven_profiles_combine_settings_and_tool_window_choices() {
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
}
#[test]
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
let settings =
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn thread_count_round_trips_and_defaults_to_empty() {
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
@@ -1766,26 +1216,6 @@ mod maven_linkage_tests {
}
}
#[cfg(test)]
mod java_settings_tests {
use super::*;
#[test]
fn shared_jdtls_defaults_off_and_round_trips() {
for old in [r#"{}"#, r#"{"jdk":"/jdk"}"#] {
let java: JavaSettings = serde_json::from_str(old).unwrap();
assert!(!java.jdtls_shared);
}
for shared in [false, true] {
let java = JavaSettings { jdtls_shared: shared, ..JavaSettings::default() };
let json = serde_json::to_value(&java).unwrap();
assert_eq!(json["jdtlsShared"], serde_json::json!(shared));
assert_eq!(serde_json::from_value::<JavaSettings>(json).unwrap(), java);
}
assert!(serde_json::from_str::<JavaSettings>(r#"{"jdtlsShared":"true"}"#).is_err());
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
-151
View File
@@ -1,151 +0,0 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashSet;
use std::time::{Duration, Instant};
pub const CHECK_INTERVAL: Duration = Duration::from_secs(4 * 60 * 60);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum UpdateSource {
#[default]
Github,
Cnb,
}
impl UpdateSource {
pub fn resolve(preference: &str, locale: &str, system_locale: &str) -> Self {
match preference {
"github" => return Self::Github,
"cnb" => return Self::Cnb,
_ => {}
}
let locale = locale.trim();
let locale = if matches!(locale, "" | "auto" | "system") { system_locale } else { locale };
if locale.split(['-', '_']).next().is_some_and(|base| base.eq_ignore_ascii_case("zh")) {
Self::Cnb
} else {
Self::Github
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
/// Unix milliseconds of the last successful check in this process.
pub last_checked: Option<f64>,
pub checking: bool,
}
/// One schedule and announcement history for the desktop process, independent of its windows.
#[derive(Default)]
pub struct UpdatePolicy {
attempted: Option<Instant>,
pub status: UpdateStatus,
announced: HashSet<String>,
}
impl UpdatePolicy {
pub fn due_in(&self, now: Instant) -> Duration {
self.attempted.map_or(Duration::ZERO, |at| CHECK_INTERVAL.saturating_sub(now.saturating_duration_since(at)))
}
pub fn begin(&mut self, now: Instant) {
self.attempted = Some(now);
self.status.checking = true;
}
pub fn finish(&mut self, checked_at: Option<f64>) {
self.status.checking = false;
if let Some(at) = checked_at {
self.status.last_checked = Some(at);
}
}
pub fn announced(&self, version: &str) -> bool {
self.announced.contains(version)
}
/// Manual checks always show their result, but also count as an announcement.
pub fn announce(&mut self, version: &str) -> bool {
self.announced.insert(version.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_and_manual_checks_resolve_the_same_preferred_source() {
for (preference, locale, system, expected) in [
("auto", "zh-CN", "en-US", UpdateSource::Cnb),
("auto", " ZH_CN ", "en-US", UpdateSource::Cnb),
("auto", "system", "zh-CN", UpdateSource::Cnb),
("auto", "auto", "zh-TW", UpdateSource::Cnb),
("auto", "", "zh", UpdateSource::Cnb),
("auto", "en", "zh-CN", UpdateSource::Github),
("github", "zh", "zh-CN", UpdateSource::Github),
("cnb", "en", "en-US", UpdateSource::Cnb),
] {
assert_eq!(UpdateSource::resolve(preference, locale, system), expected);
}
}
#[test]
fn checks_at_startup_and_every_four_hours_even_after_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
assert_eq!(policy.due_in(now), Duration::ZERO);
policy.begin(now);
assert!(policy.status.checking);
policy.finish(None);
assert_eq!(policy.status.last_checked, None);
assert_eq!(policy.due_in(now + CHECK_INTERVAL - Duration::from_secs(1)), Duration::from_secs(1));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::ZERO);
policy.begin(now + CHECK_INTERVAL);
policy.finish(Some(1234.0));
assert_eq!(policy.status.last_checked, Some(1234.0));
assert!(!policy.status.checking);
assert_eq!(policy.due_in(now + CHECK_INTERVAL), CHECK_INTERVAL);
}
#[test]
fn manual_checks_reset_the_schedule_without_losing_last_success_on_failure() {
let mut policy = UpdatePolicy::default();
let now = Instant::now();
policy.begin(now);
policy.finish(Some(1000.0));
policy.begin(now + Duration::from_secs(60));
policy.finish(None);
assert_eq!(policy.status.last_checked, Some(1000.0));
assert_eq!(policy.due_in(now + CHECK_INTERVAL), Duration::from_secs(60));
}
#[test]
fn announcements_are_per_version_across_windows_sources_and_manual_checks() {
let mut policy = UpdatePolicy::default();
assert!(policy.announce("0.1.40"));
assert!(!policy.announce("0.1.40"));
assert!(policy.announce("0.1.41"));
assert!(!policy.announce("0.1.40"));
assert!(UpdatePolicy::default().announce("0.1.40"));
}
}
+1 -22
View File
@@ -43,7 +43,6 @@ impl Default for VcsSettings {
pub struct GitSettings {
/// Show blame after the current editor line.
pub inline_blame: bool,
pub show_branch_in_project_view: bool,
/// Path to the git executable; empty = auto-detect.
pub executable: String,
/// Default of Update Project: `merge` | `rebase`.
@@ -75,7 +74,6 @@ impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
show_branch_in_project_view: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
@@ -115,15 +113,6 @@ pub struct CommitSettings {
pub body_limit: u32,
pub show_right_margin: bool,
pub wrap_at_margin: bool,
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
pub disabled_plugin_checks: Vec<String>,
/// Double-click / Enter in the changes view: `None` follows the interface (the
/// Commit tool window opens the diff, Local Changes the source file).
///
/// No `skip_serializing_if`: an unset value must still appear in
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
#[specta(optional)]
pub double_click_opens_diff: Option<bool>,
}
impl Default for CommitSettings {
@@ -143,8 +132,6 @@ impl Default for CommitSettings {
body_limit: 72,
show_right_margin: true,
wrap_at_margin: false,
disabled_plugin_checks: Vec::new(),
double_click_opens_diff: None,
}
}
}
@@ -282,8 +269,6 @@ impl Default for PythonDebuggerSettings {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunSettings {
/// Target for configurations without an explicit target. Empty = local.
pub default_target: String,
pub confirm_rerun: bool,
/// Ask before closing a Run tab whose process is still running.
pub confirm_terminate: bool,
@@ -294,13 +279,7 @@ pub struct RunSettings {
impl Default for RunSettings {
fn default() -> Self {
Self {
default_target: String::new(),
confirm_rerun: true,
confirm_terminate: true,
show_on_start: true,
termination_timeout_ms: 3000,
}
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ libc = "0.2"
[dev-dependencies]
tracing-subscriber.workspace = true
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+76 -345
View File
@@ -101,15 +101,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
"gdb",
&["-i", "dap"],
"stdio",
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
),
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
"com.microsoft.java.debug.plugin.jar",
@@ -135,7 +126,6 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
"delve" => "go",
"netcoredbg" => "coreclr",
"lldb-dap" => "lldb-dap",
"gdb" => "gdb",
"java-debug" => "java",
_ => return None,
})
@@ -175,11 +165,6 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
}
/// A port that was free a moment ago on the loopback interface.
///
/// Inherently racy: another process can bind the port between the listener being
/// dropped here and the adapter binding it. Callers that hand the port to a child
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
pub fn free_port() -> std::io::Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
Ok(listener.local_addr()?.port())
@@ -341,89 +326,6 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
)))
}
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
/// "Interpreter `dap' unrecognized".
const GDB_MIN_MAJOR: u32 = 14;
/// `(major, minor)` of the first version number in the output of `gdb --version`
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
let line = output.lines().next()?;
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
if !b[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let major: u32 = line[start..i].parse().ok()?;
let mut minor = 0;
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
i += 1;
let ms = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
minor = line[ms..i].parse().unwrap_or(0);
}
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
continue;
}
return Some((major, minor));
}
None
}
/// Probe the already resolved and verified executable with its launch environment.
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
let mut probe = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
probe.env("PATH", path);
}
ide_core::process::prepare(probe.as_std_mut());
let probe = probe
.arg("--version")
.current_dir(&spec.cwd)
.envs(spec.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
return Ok(None);
};
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
}
/// Refuse old GDB versions and report whether launch must follow configurationDone.
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
let Some((major, minor)) = gdb_version(binary, spec).await? else {
return Ok(false);
};
if major >= GDB_MIN_MAJOR {
return Ok(major == 15);
}
let mut msg = format!(
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
this one has no DAP interpreter (`gdb -i dap`)"
);
if let Some(hint) = &spec.install_hint {
msg.push_str(". ");
msg.push_str(hint);
}
Err(IdeError::unavailable(msg))
}
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
/// than a program: it has no process of its own and is reached through `debugServer`.
pub fn is_plugin_bundle(command: &str) -> bool {
@@ -457,8 +359,6 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
/// A connected adapter: byte streams plus the process (if we spawned one).
pub struct AdapterIo {
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
pub launch_after_configuration: bool,
pub reader: BoxReader,
pub writer: BoxWriter,
pub child: Option<Child>,
@@ -473,8 +373,8 @@ pub struct AdapterIo {
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
/// These debuggees inherit adapter stdio; no DAP event carries their output.
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
const SHARES_STDIO: &str = "delve";
const DELVE_BANNER: &str = "DAP server listening at:";
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
@@ -493,9 +393,6 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
/// Probe of a `connect` address before starting the command.
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
/// claimed by another process before the adapter binds it, so a new one usually wins.
const PORT_ATTEMPTS: u32 = 3;
impl AdapterLauncher for ProcessLauncher {
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
@@ -510,7 +407,6 @@ impl AdapterLauncher for ProcessLauncher {
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -534,7 +430,6 @@ impl AdapterLauncher for ProcessLauncher {
let _ = stream.set_nodelay(true);
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -563,152 +458,87 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let launch_after_configuration =
if spec.adapter == "gdb" { prepare_gdb(&spec, &binary).await? } else { false };
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => {
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
io.launch_after_configuration = launch_after_configuration;
Ok(io)
let port = match spec.transport {
// An embedded adapter started without `${port}` listens on its usual address.
Transport::Tcp => match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => Some(free_port()?),
},
Transport::Stdio => None,
};
let args = substitute_root(&spec.args, &spec.cwd);
let args = match port {
Some(port) => substitute_port(&args, port),
None => args,
};
let mut cmd = Command::new(&binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(&args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
// retried if another process takes it before the adapter binds.
let fixed = match &embedded {
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
_ => None,
};
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
for attempt in 1..=PORT_ATTEMPTS {
let port = match fixed {
Some(port) => port,
None => free_port()?,
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(mut io) => {
io.launch_after_configuration = launch_after_configuration;
return Ok(io);
}
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
target: "ide_dap::adapter",
"debug adapter failed to start on port {port}: {}; retrying",
e.message
);
continue;
}
return Err(e);
}
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
unreachable!("the retry loop returns on its last attempt")
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
Ok(s) => s,
Err(e) => {
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
})
}
}
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
async fn spawn_adapter(
spec: &AdapterSpec,
binary: &Path,
args: &[String],
port: Option<u16>,
start_timeout: Duration,
) -> IdeResult<AdapterIo> {
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
let mut cmd = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
cmd.env("PATH", path);
}
ide_core::process::prepare(cmd.as_std_mut());
// Every adapter leads its own process group: the program an adapter launches itself
// (java-debug's JVM) joins the group, so a forced stop reaches the whole tree with
// one `kill(-pgid)`. On Windows the same coverage comes from the session's job object.
ide_core::process::own_group(cmd.as_std_mut());
cmd.args(args)
.envs(spec.env.iter().map(|(k, v)| (k, v)))
.current_dir(&spec.cwd)
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child =
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
if let Some(err) = child.stderr.take() {
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
}
match port {
None => {
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
port: None,
stderr,
program_output,
})
}
Some(port) => {
if let Some(out) = child.stdout.take() {
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
}
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
Ok(s) => s,
Err(e) => {
if spec.adapter == "rdbg"
&& let Some(pid) = child.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = child.start_kill();
let tail = stderr.lock().join("\n");
return Err(if tail.is_empty() {
e
} else {
IdeError::unavailable(format!("{}\n{tail}", e.message))
});
}
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
port: Some(port),
stderr,
program_output,
})
}
}
}
/// A failed adapter start that another process grabbing the free port can explain
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
/// it could not bind). Anything else is a real start failure and is not retried.
fn is_port_race(err: &IdeError) -> bool {
let message = err.message.to_ascii_lowercase();
message.contains("address already in use")
|| message.contains("addrinuse")
|| message.contains("only one usage of each socket address")
|| message.contains("exited before accepting connections")
}
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
stream: R,
name: String,
@@ -844,102 +674,15 @@ mod tests {
#[test]
fn builtins_cover_common_adapters() {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
let cfg = builtin(name).unwrap();
assert!(cfg.install_hint.is_some());
}
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
assert_eq!(delve.transport, Transport::Tcp);
// GDB's own DAP interpreter over stdio, with the required version in its hint.
let gdb = builtin("gdb").unwrap();
assert_eq!(gdb.command, "gdb");
assert_eq!(gdb.args, ["-i", "dap"]);
assert_eq!(gdb.transport, "stdio");
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
assert_eq!(default_type("gdb"), Some("gdb"));
assert!(builtin("nope").is_none());
}
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
#[test]
fn parses_gdb_versions() {
for (out, want) in [
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
("GNU gdb 14", Some((14, 0))),
("no version here", None),
("", None),
] {
assert_eq!(parse_gdb_version(out), want, "{out}");
}
}
#[tokio::test]
async fn gdb_probe_never_executes_a_changed_pinned_program() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
std::fs::write(&fake, "approved").unwrap();
let pin = ProgramPin::approve(&fake).unwrap();
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
spec.command = fake.to_string_lossy().into_owned();
spec.pinned = Some(pin);
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
}
#[tokio::test]
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let pin = ProgramPin::approve(&fake).unwrap();
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
// Only the approved path is executable; the symbolic command must never be probed.
spec.command = "definitely-not-a-real-binary-openidea".into();
spec.pinned = Some(pin.clone());
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
let result = ProcessLauncher.launch(spec, None).await;
if version == "12.1" {
let error = result.err().expect("GDB 12 has no DAP interpreter");
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
assert!(error.message.contains("12.1"), "{}", error.message);
} else {
let mut io = result.unwrap();
assert_eq!(io.launch_after_configuration, deferred, "{version}");
let mut output = String::new();
io.reader.read_to_string(&mut output).await.unwrap();
assert_eq!(output.trim(), "adapter");
io.child.as_mut().unwrap().wait().await.unwrap();
}
}
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
}
#[test]
fn free_port_is_nonzero() {
assert_ne!(free_port().unwrap(), 0);
@@ -1023,16 +766,4 @@ mod tests {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
}
#[test]
fn port_conflicts_are_recognised_for_retry() {
let in_use = IdeError::unavailable(
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
);
assert!(is_port_race(&in_use));
assert!(is_port_race(&IdeError::unavailable(
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
)));
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
}
}
+7 -36
View File
@@ -246,7 +246,13 @@ impl Connection {
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
let Some(msg) = parse_message(body) else { return };
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("dap: unparsable message: {e}");
return;
}
};
match msg.get("type").and_then(Value::as_str) {
Some("response") => {
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
@@ -279,25 +285,6 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
}
}
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
/// output (UTF-8, else the system code page) before parsing.
fn parse_message(body: &[u8]) -> Option<Value> {
let error = match serde_json::from_slice(body) {
Ok(v) => return Some(v),
Err(e) => e,
};
if std::str::from_utf8(body).is_err()
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
{
return Some(v);
}
tracing::warn!("dap: unparsable message: {error}");
None
}
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
fn error_message(msg: &Value) -> String {
if let Some(err) = msg.pointer("/body/error")
@@ -319,22 +306,6 @@ fn error_message(msg: &Value) -> String {
mod tests {
use super::*;
#[test]
fn parses_output_events_with_non_utf8_text() {
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
body.extend_from_slice(br#"\n"}}"#);
let msg = parse_message(&body).expect("parsed");
assert_eq!(msg["event"], "output");
let output = msg["body"]["output"].as_str().unwrap();
assert!(output.ends_with('\n'));
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
#[cfg(not(windows))]
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
assert!(parse_message(b"{not json").is_none());
}
#[test]
fn formats_structured_errors() {
let msg =
+25 -150
View File
@@ -282,6 +282,7 @@ impl SessionOwner {
}
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// Redefining many classes in a large JVM can take a while.
@@ -304,63 +305,6 @@ pub struct DebugManager {
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
/// arguments and environment: they go on its command line after `--` and into its process.
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
if config.request != "launch" {
return Ok(());
}
if spec.command == "rdbg"
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
{
if !bundles_debug(&gemfile) {
return Err(IdeError::unavailable(format!(
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
gemfile.display()
)));
}
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
spec.command = "bundle".into();
}
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
spec.args.push("--".into());
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
// without arguments go through -S so spaces and metacharacters stay literal.
if config.program.is_some() && config.args.is_empty() {
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
}
spec.args.extend(config.program.iter().cloned());
spec.args.extend(config.args.iter().cloned());
Ok(())
}
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
let gemfile = env
.get("BUNDLE_GEMFILE")
.cloned()
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
match gemfile.filter(|g| !g.is_empty()) {
Some(gemfile) => Some(dir.join(gemfile)),
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
}
}
/// Whether the lockfile of `gemfile` lists the `debug` gem.
fn bundles_debug(gemfile: &Path) -> bool {
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
gemfile.with_file_name("gems.locked")
} else {
let mut name = gemfile.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
};
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
@@ -607,22 +551,11 @@ fn remove_delve_binaries(cwd: &Path) {
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
// Windows: a just-killed dlv / debuggee can still hold this session's
// binary while the previous one's lock is already gone, so one try
// leaves exactly the current leftover behind. Retry on a short back-off
// to outlast the handle release; a truly busy file is retried into the
// next run's sweep anyway.
for attempt in 0..5 {
match std::fs::remove_file(entry.path()) {
Ok(()) => break,
Err(e) if attempt == 4 => {
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
}
if leftover
&& entry.file_type().is_ok_and(|t| t.is_file())
&& let Err(e) = std::fs::remove_file(entry.path())
{
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
}
}
}
@@ -718,13 +651,6 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
// Delve looks up Go before applying the debuggee's launch environment.
if config.adapter == "delve" {
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
@@ -754,18 +680,6 @@ impl DebugManager {
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
// instead belong on the adapter process, which the inferior inherits. Explicit
// launch JSON keeps GDB's replacement semantics.
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
});
if !explicit_env {
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
arguments.as_object_mut().unwrap().remove("env");
}
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -875,29 +789,19 @@ impl DebugManager {
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
}
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
};
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
.flatten();
let session =
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
// rdbg execs the debuggee in its own process and sends no DAP process event.
if let Some(pid) = rdbg_pid {
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
}
if let Some(mut output) = io.program_output {
session.output_drained.send_replace(false);
let session = session.clone();
tokio::spawn(async move {
while let Some((category, output)) = output.recv().await {
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
}
session.output_drained.send_replace(true);
});
}
self.sessions.lock().insert(id, session.clone());
@@ -910,12 +814,7 @@ impl DebugManager {
});
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments, launch_after_configuration) => result,
};
match configured {
match self.configure(&session, arguments).await {
Ok(()) => Ok(id),
Err(e) => {
let e = if session.conn.is_closed() {
@@ -940,7 +839,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
@@ -1003,32 +902,19 @@ impl DebugManager {
arguments["hostName"] = json!("127.0.0.1");
arguments["port"] = json!(port);
}
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
let mut launch = if launch_in_config {
None
} else {
let reply = s.conn.send_request(request, arguments.clone())?;
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
};
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = match launch.as_mut() {
Some(launch) => tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = launch => {
r?;
launch_result = Some(());
false
}
},
None => {
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
true
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
}
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
};
if !got_initialized {
// Launched before `initialized`; give the adapter a moment to send it.
@@ -1085,16 +971,12 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
if launch_result.is_none() {
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
Ok(r) => {
r?;
}
}
None => {
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
let reply = s.conn.send_request(request, arguments)?;
reply.await.unwrap_or(Err(DapError::Closed))?;
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
@@ -1115,18 +997,13 @@ impl DebugManager {
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
if terminate {
crate::session::remember_debuggee(s).await;
}
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
// ends that DAP session, so every line the program writes while shutting down is lost.
// Ask the program to exit ourselves and keep the adapter attached until it has drained
// the console (`terminated`), as IDEA does with the process it owns.
let pid = s.debuggee_pid.load(Ordering::Acquire);
let own_terminate = terminate && s.request == "launch" && pid > 0;
// rdbg's terminate exits the Ruby process without terminating its children.
let graceful =
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
if !s.conn.is_closed() {
if graceful {
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
@@ -1145,7 +1022,7 @@ impl DebugManager {
if own_terminate && !graceful {
// Signal before anything else: waiting on a request the adapter may not answer
// would delay the shutdown the console is supposed to show.
crate::session::signal_debuggee(s, false).await;
crate::session::signal_debuggee(s, false);
}
if graceful {
// java-debug sends `terminated` only after it has drained the program's console
@@ -1782,11 +1659,9 @@ impl DebugManager {
}
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
session.startup_cancel.cancel();
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
for child in children {
child.startup_cancel.cancel();
self.shutdown(&child, true).await;
}
self.shutdown(&session, true).await;
+28 -255
View File
@@ -45,18 +45,9 @@ pub(crate) struct Session {
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// The job object over the adapter and everything it started: the program an adapter
/// like java-debug launches is its child, so a forced stop (and the session's
/// cleanup) ends the whole tree even when the adapter exited first and the reported
/// pid no longer names it. `None` when the assignment failed; the kill then falls
/// back to the captured process tree.
#[cfg(windows)]
pub job: Mutex<Option<ide_core::process::Job>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
pub startup_cancel: ide_core::CancellationToken,
pub output_drained: watch::Sender<bool>,
pub terminated: AtomicBool,
/// Set by a second Stop: the one waiting for the program kills it instead.
pub stop_force: AtomicBool,
@@ -66,8 +57,6 @@ pub(crate) struct Session {
/// adapter may report the launcher shell, whose child is the program).
#[cfg_attr(not(unix), allow(dead_code))]
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
#[cfg(windows)]
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// Adapter breakpoint id → (file, index in the stored list).
@@ -98,10 +87,6 @@ impl Session {
port: Option<u16>,
child: Option<Child>,
) -> Self {
// Before `child` moves into the session: the adapter is the root of the job, so
// everything it starts (java-debug's JVM) is in there by inheritance.
#[cfg(windows)]
let job = child.as_ref().and_then(tokio::process::Child::id).and_then(ide_core::process::Job::for_pid);
Self {
id,
owner,
@@ -116,18 +101,12 @@ impl Session {
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
#[cfg(windows)]
job: Mutex::new(job),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
startup_cancel: ide_core::CancellationToken::new(),
output_drained: watch::channel(true).0,
terminated: AtomicBool::new(false),
stop_force: AtomicBool::new(false),
debuggee_pid: AtomicI64::new(0),
stop_pids: parking_lot::Mutex::new(Vec::new()),
#[cfg(windows)]
stop_tree: Mutex::new(None),
pending_restore: Mutex::default(),
breakpoint_ids: Mutex::new(HashMap::new()),
resolved_paths: Mutex::new(HashMap::new()),
@@ -295,30 +274,7 @@ impl Session {
/// Kills the adapter and every debuggee we started.
pub fn kill_processes(&self) {
self.kotlin_relay.lock().take();
#[cfg(unix)]
if self.request == "launch" {
for group in launch_groups(self) {
ide_core::process::kill_tree_of(group as u32);
}
}
// The job first: it ends the adapter and everything it started (the program
// java-debug launched is its child), members included that the stop's captured
// tree never saw. An empty job is unaffected, and whatever survives this session
// dies when the handle closes (`KILL_ON_JOB_CLOSE`).
#[cfg(windows)]
if let Some(job) = self.job.lock().as_ref() {
job.terminate();
}
#[cfg(windows)]
if let Some(tree) = self.stop_tree.lock().as_ref() {
tree.terminate();
}
if let Some(mut c) = self.adapter_process.lock().take() {
if self.adapter == "rdbg"
&& let Some(pid) = c.id()
{
ide_core::process::kill_tree_of(pid);
}
let _ = c.start_kill();
}
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
@@ -326,9 +282,6 @@ impl Session {
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
let _ = std::thread::Builder::new().name("openidea-debug-reap".into()).spawn(move || {
let _ = program.child.wait();
});
}
}
}
@@ -336,21 +289,6 @@ impl Session {
/// How long Stop waits for a launched program to exit before reporting the session ended.
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
#[cfg(windows)]
{
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
return;
}
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
s.stop_tree.lock().get_or_insert(tree);
}
}
#[cfg(not(windows))]
let _ = s;
}
/// The program and everything it started: an adapter may report the launcher shell, whose
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
#[cfg(unix)]
@@ -380,36 +318,18 @@ fn process_tree(pid: i32) -> Vec<i32> {
/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session:
/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid
/// tree, because the reported pid may die before its program does.
pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
if s.request != "launch" {
pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
let pid = s.debuggee_pid.load(Ordering::Acquire);
if pid <= 0 {
return;
}
#[cfg(not(windows))]
let pid = s.debuggee_pid.load(Ordering::Acquire);
#[cfg(unix)]
{
let needs_tree = pid > 0 && s.stop_pids.lock().is_empty();
if needs_tree {
let tree = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
*s.stop_pids.lock() = tree;
let mut known = s.stop_pids.lock();
if known.is_empty() {
*known = process_tree(pid as i32);
}
let known = s.stop_pids.lock();
let signal = if force { libc::SIGKILL } else { libc::SIGTERM };
if force {
// A forced stop kills the launch: every root the session spawned leads its own
// process group (`own_group` at spawn), so one signal reaches the program and
// everything it started, even processes born after the snapshot below was
// taken — the adapter's program is in the adapter's group. The session's own
// JVM roots (Kotlin) and `runInTerminal` programs lead groups of their own.
// The roots are unreaped until the session drops them, so their ids (and the
// groups named by them) cannot have been reused yet.
for group in launch_groups(s) {
// SAFETY: plain syscalls on the processes this session started.
if unsafe { libc::kill(-group, libc::SIGKILL) } != 0 {
unsafe { libc::kill(group, libc::SIGKILL) };
}
}
}
// Children first: the shell that started the program would otherwise reparent it.
for p in known.iter().rev() {
// SAFETY: plain syscall on the processes this session started.
@@ -420,53 +340,17 @@ pub(crate) async fn signal_debuggee(s: &Arc<Session>, force: bool) {
}
#[cfg(windows)]
{
// Console programs have no external graceful signal; the adapter gets its chance
// first. The job ends the program and everything it started, members included
// that the captured tree never saw.
let mut cmd = std::process::Command::new("taskkill");
cmd.args(["/PID", &pid.to_string(), "/T"]);
if force {
if let Some(job) = s.job.lock().as_ref() {
job.terminate();
}
// A separately spawned program may have rejected assignment to the adapter's job.
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.terminate();
}
cmd.arg("/F");
}
let _ = cmd.status();
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, force);
}
/// The process groups the session spawned roots for: the adapter's (the program it
/// launched is in it), the JVMs the session started itself, the `runInTerminal` program.
#[cfg(unix)]
fn launch_groups(s: &Session) -> Vec<libc::pid_t> {
let mut groups = Vec::new();
let mut push = |id: Option<u32>| {
if let Ok(pid) = libc::pid_t::try_from(id.unwrap_or(0)) {
groups.push(pid);
}
};
push(s.adapter_process.lock().as_ref().and_then(tokio::process::Child::id));
for child in s.debuggees.lock().iter() {
push(child.id());
}
push(s.program.lock().as_ref().and_then(|p| p.child.process_id()));
groups.retain(|g| *g > 0);
groups
}
/// Whether the launch is still running: the stop's captured tree when there is one,
/// otherwise the session's job (the adapter and everything it started).
#[cfg(windows)]
fn launch_running(s: &Arc<Session>) -> bool {
if let Some(tree) = s.stop_tree.lock().as_ref() {
tree.is_running()
} else {
s.job.lock().as_ref().is_some_and(|job| job.is_running())
}
}
/// Waits until the program a `launch` session started is gone; a second Stop kills it.
pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
if !terminate || s.request != "launch" {
@@ -480,39 +364,25 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
#[cfg(unix)]
{
use std::time::Instant;
let mut targets = s.stop_pids.lock().clone();
if targets.is_empty() {
targets = tokio::task::spawn_blocking(move || process_tree(pid as i32)).await.unwrap_or_default();
}
let targets = {
let known = s.stop_pids.lock();
if known.is_empty() { process_tree(pid as i32) } else { known.clone() }
};
let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0);
let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT;
while alive() {
if s.stop_force.swap(false, Ordering::AcqRel) {
signal_debuggee(s, true).await;
signal_debuggee(s, true);
}
if Instant::now() >= deadline {
// The program ignored the terminate: do not leave it running.
signal_debuggee(s, true).await;
signal_debuggee(s, true);
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(windows)]
{
remember_debuggee(s).await;
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
// The captured tree is the oracle when it was captured; otherwise the job (every
// process the launch started) is, so a failed capture can no longer end the wait
// while the program still holds its port.
while launch_running(s) {
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
signal_debuggee(s, true).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(not(any(unix, windows)))]
#[cfg(not(unix))]
{
let _ = pid;
}
@@ -578,11 +448,6 @@ pub(crate) async fn event_loop(
Incoming::Closed => break,
}
}
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
if session.adapter == "rdbg" && session.request == "launch" {
let mut drained = session.output_drained.subscribe();
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
}
if let Some(mgr) = manager.upgrade() {
mgr.session_closed(&session);
}
@@ -723,14 +588,15 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: ide_core::input::InputWriter,
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write(text.as_bytes())
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
@@ -750,16 +616,6 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
#[cfg(unix)]
let wait_writable = ide_core::input::nonblocking(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
#[cfg(unix)]
let wait_readable = ide_core::input::readable(
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
)
.map_err(|e| format!("terminal of {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
@@ -785,39 +641,12 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
// The program joins the session's job, so Stop reaches what it starts (its children),
// though only the program itself is the session's child.
#[cfg(windows)]
if let Some(pid) = child.process_id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = ide_core::input::InputWriter::new(
format!("openidea-debug-input-{}", s.id.0),
writer,
#[cfg(unix)]
wait_writable,
|e| {
tracing::warn!("debug input: {e}");
},
)
.inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
.map_err(|e| format!("terminal input worker: {e}"))?;
let pid = child.process_id();
forward_terminal_output(
s,
reader,
#[cfg(unix)]
wait_readable,
);
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
}
@@ -826,11 +655,7 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(
s: &Session,
mut reader: Box<dyn std::io::Read + Send>,
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
) {
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
@@ -838,27 +663,8 @@ fn forward_terminal_output(
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
#[cfg(unix)]
if let Err(e) = wait_readable()
&& e.kind() != std::io::ErrorKind::Interrupted
{
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
#[cfg(not(unix))]
std::thread::sleep(std::time::Duration::from_millis(4));
continue;
}
#[cfg(unix)]
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
Err(e) => {
tracing::warn!(%session, "debug terminal read: {e}");
break;
}
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
@@ -920,6 +726,7 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
use tokio::io::{AsyncBufReadExt, BufReader};
let port = crate::adapter::free_port()?;
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
let strings = |key: &str| -> Vec<String> {
args.get(key)
@@ -932,9 +739,7 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
let mut cmd = tokio::process::Command::new(java);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(strings("vmArguments"))
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
// port here races with other processes that may take it before the JVM binds.
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
.arg("-cp")
.arg(classpath);
let modules = strings("modulePaths");
@@ -961,33 +766,20 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
}
}
}
// A root the session spawns itself: its own process group on Unix (`kill(-pgid)` in a
// forced stop), the session's job on Windows, so a stop reaches what it forks.
ide_core::process::own_group(cmd.as_std_mut());
let mut child = cmd.spawn()?;
#[cfg(windows)]
if let Some(pid) = child.id()
&& let Some(job) = s.job.lock().as_ref()
{
job.assign_pid(pid);
}
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
let mut stdout =
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
// the free-port race window entirely.
let port = tokio::time::timeout(Duration::from_secs(15), async {
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let mut line = String::new();
if stdout.read_line(&mut line).await? == 0 {
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
}
if line.contains("Listening for transport dt_socket") {
return parse_jdwp_port(&line).ok_or_else(|| {
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
});
return Ok(());
}
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
}
@@ -1001,22 +793,3 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
args["timeout"] = json!(10000);
Ok(())
}
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
fn parse_jdwp_port(line: &str) -> Option<u16> {
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::parse_jdwp_port;
#[test]
fn reads_the_port_the_jvm_chose() {
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
assert_eq!(parse_jdwp_port("unrelated output"), None);
}
}
+18 -541
View File
@@ -14,93 +14,6 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
type Log = Arc<Mutex<Vec<Value>>>;
#[cfg(windows)]
#[test]
#[ignore = "child process for Windows stop tests"]
fn windows_debuggee_process() {
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
return;
}
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
let mut child = ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
.spawn()
.unwrap();
let _ = child.wait();
return;
}
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
println!("LISTENING {}", listener.local_addr().unwrap().port());
std::thread::sleep(Duration::from_secs(60));
}
#[cfg(windows)]
#[tokio::test]
async fn windows_stop_releases_the_debuggees_port_before_returning() {
use std::io::BufRead;
use std::sync::atomic::Ordering;
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
impl Drop for Child {
fn drop(&mut self) {
if let Some(tree) = &self.1 {
tree.terminate();
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
for (second_stop, wrapper, supports_terminate) in
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
{
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
ide_core::process::prepare(&mut command)
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
.stdout(std::process::Stdio::piped());
let mut child = Child(command.spawn().unwrap(), None);
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
.lines()
.map(Result::unwrap)
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
.unwrap();
let pid = child.0.id();
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
let stop = tokio::spawn({
let manager = h.manager.clone();
async move { manager.stop(id).await }
});
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while session.stop_tree.lock().is_none() && !stop.is_finished() {
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!stop.is_finished(), "Stop must wait for the live program");
if wrapper {
assert!(session.stop_tree.lock().is_some());
child.0.kill().unwrap();
child.0.wait().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
}
if second_stop {
h.manager.stop(id).await.unwrap();
}
let limit = if second_stop { 10 } else { 25 };
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
}
}
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
@@ -120,16 +33,6 @@ struct Script {
supports_terminate: bool,
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
hold_set_breakpoints: usize,
initialized_delay: Duration,
launch_delay: Duration,
/// Leave startup pending before `initialized` (false) or after configuration (true).
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
close_during_startup: bool,
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
}
impl Default for Script {
@@ -144,13 +47,6 @@ impl Default for Script {
output_after_terminate: false,
supports_terminate: true,
hold_set_breakpoints: 0,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
}
}
}
@@ -165,12 +61,10 @@ impl AdapterLauncher for FakeLauncher {
let (client, server) = tokio::io::duplex(1 << 16);
let log = self.log.clone();
let script = self.script.clone();
let launch_after_configuration = script.launch_after_configuration;
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo {
launch_after_configuration,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -188,7 +82,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut running = false;
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -209,46 +102,24 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => {
ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
);
if script.gdb15_starts_at_launch {
// GDB 15 announces itself before it sees `launch` (which a client that
// defers it sends late).
send!(json!({"type": "event", "event": "initialized"}));
}
}
"initialize" => ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
),
"launch" | "attach" => {
if script.gdb15_starts_at_launch {
// GDB 15 starts the program here and answers `launch` right away.
running = true;
ok!(msg, Value::Null);
continue;
}
if script.close_during_startup {
return;
}
if let Some(message) = script.startup_error {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
);
continue;
}
pending_launch = msg["seq"].as_i64();
if let Some(args) = &script.run_in_terminal {
send!(
@@ -260,10 +131,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
);
}
if script.pending_startup != Some(false) {
tokio::time::sleep(script.initialized_delay).await;
send!(json!({"type": "event", "event": "initialized"}));
}
send!(json!({"type": "event", "event": "initialized"}));
}
"setBreakpoints" => {
held.push(msg);
@@ -311,21 +179,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if script.gdb15_starts_at_launch && running {
// GDB 15 answers `configurationDone` with `notStopped` while the program
// it started at `launch` is still running.
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
);
continue;
}
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
if script.pending_startup == Some(true) {
continue;
}
tokio::time::sleep(script.launch_delay).await;
if let Some(l) = pending_launch.take() {
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
}
@@ -495,119 +351,6 @@ fn bp(line: u32) -> Breakpoint {
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
}
#[tokio::test(start_paused = true)]
async fn slow_launch_and_attach_follow_adapter_startup() {
for request in ["launch", "attach"] {
for before_initialized in [true, false] {
let delay = Duration::from_secs(125);
let h = harness(Script {
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
launch_delay: if before_initialized { Duration::ZERO } else { delay },
..Script::default()
});
let config = LaunchConfig { request: request.into(), ..config() };
let id = h.start(config).await.expect("a slow adapter can finish starting");
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
h.manager.stop(id).await.unwrap();
}
}
}
#[tokio::test(start_paused = true)]
async fn stop_cancels_pending_launch_and_attach() {
for request in ["launch", "attach"] {
for configured in [false, true] {
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
let config = LaunchConfig { request: request.into(), ..config() };
let start = h.start(config);
tokio::pin!(start);
let stop = async {
loop {
let command = if configured { "configurationDone" } else { request };
if h.commands().iter().any(|c| c == command) {
break;
}
tokio::task::yield_now().await;
}
let id = h.manager.sessions(None)[0].id;
h.manager.stop(id).await.unwrap();
};
let (result, ()) = tokio::join!(&mut start, stop);
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
assert!(h.manager.sessions(None).is_empty());
}
}
}
/// GDB 15 must receive breakpoints and configurationDone before launch.
#[tokio::test(start_paused = true)]
async fn gdb_15_launch_waits_for_the_configuration() {
for deferred in [false, true] {
let h =
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
if deferred {
let id = result.expect("GDB 15 defers its launch past the configuration");
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
} else {
let error = result.unwrap_err();
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
}
}
}
#[tokio::test(start_paused = true)]
async fn gdb_attach_keeps_the_normal_configuration_order() {
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn gdb_run_environment_overrides_preserve_inheritance() {
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
let h = harness(Script::default());
let id = h
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
.await
.unwrap();
let session = h.manager.session(id).unwrap();
{
let log = h.log.lock();
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
if explicit.is_some() {
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
assert!(session.spec.env.is_empty());
} else {
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
assert!(session.spec.env.contains(&("A".into(), "1".into())));
}
}
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test(start_paused = true)]
async fn startup_still_reports_adapter_failure_and_connection_closure() {
for request in ["launch", "attach"] {
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
assert!(h.manager.sessions(None).is_empty());
let h = harness(Script { close_during_startup: true, ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
assert!(h.manager.sessions(None).is_empty());
}
}
#[tokio::test]
async fn full_protocol_flow() {
let h = harness(Script::default());
@@ -811,22 +554,6 @@ async fn run_in_terminal_program_reads_typed_input() {
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn blocked_program_input_does_not_hold_up_shutdown() {
let h = harness(Script {
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
let started = std::time::Instant::now();
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
assert!(started.elapsed() < std::time::Duration::from_millis(250));
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "later").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
@@ -1123,39 +850,6 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
@@ -1697,109 +1391,6 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("ruby".into()),
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(
spec.args,
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
);
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
}
#[test]
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
// A dependency of another gem is not a gem of the bundle.
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("bundle".into()),
args: ["exec", "rspec"].map(String::from).to_vec(),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, app.clone());
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
// From a subdirectory too: Bundler finds the Gemfile above it.
for cwd in [app.clone(), app.join("lib")] {
let mut spec = adapter::resolve(&rdbg, cwd.clone());
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
assert_eq!(spec.command, "bundle");
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
}
}
#[test]
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
let config =
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
}
}
#[test]
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
let mut spec = adapter::resolve(&rdbg, dir.path().into());
let args = spec.args.clone();
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(spec.args, args);
}
#[test]
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
let dir = tempfile::tempdir().unwrap();
for name in ["Gemfile", "gems.rb"] {
std::fs::write(dir.path().join(name), "").unwrap();
}
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
}
#[test]
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("gems")).unwrap();
let none = BTreeMap::new();
assert_eq!(bundler_gemfile(root, &none), None);
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
assert_eq!(gems_rb, root.join("gems/gems.rb"));
assert!(bundles_debug(&gems_rb));
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
@@ -2017,117 +1608,3 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
struct Launcher;
impl AdapterLauncher for Launcher {
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
Box::pin(async {
let (client, server) = tokio::io::duplex(1 << 16);
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
let mut command = tokio::process::Command::new("/bin/sh");
ide_core::process::own_group(command.as_std_mut());
let mut child = command
.args(["-c", "sleep 1; printf 'test summary\\n'"])
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let mut text = String::new();
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
let _ = tx.send(("stdout", text));
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
port: None,
stderr: Arc::default(),
program_output: Some(rx),
})
})
}
}
let dir = tempfile::tempdir().unwrap();
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
let events = Arc::new(Mutex::new(Vec::new()));
let collected = events.clone();
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
let id = manager
.start(
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
owner,
)
.await
.unwrap();
manager.session(id).unwrap().conn.close();
tokio::time::timeout(Duration::from_secs(5), async {
while !manager.sessions(None).is_empty() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert!(events.lock().iter().any(|e| matches!(e,
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
)));
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_ends_the_adapter_tree_without_a_process_event() {
use tokio::io::AsyncBufReadExt;
let h = harness(Script::default());
let id = h.start(config()).await.unwrap();
let session = h.manager.session(id).unwrap();
let mut command = tokio::process::Command::new("python3");
ide_core::process::own_group(command.as_std_mut());
command
.args([
"-u",
"-c",
"import subprocess,sys,time; subprocess.Popen([sys.executable, '-u', '-c', \
\"import socket,time; s=socket.socket(); s.bind(('127.0.0.1',0)); s.listen(); \
print(s.getsockname()[1],flush=True); time.sleep(60)\"]); time.sleep(60)",
])
.stdout(std::process::Stdio::piped());
let mut child = command.spawn().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let port: u16 = lines.next_line().await.unwrap().unwrap().parse().unwrap();
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the descendant holds the port");
let pid = child.id().unwrap();
*session.adapter_process.lock() = Some(child);
session.kill_processes();
let deadline = Instant::now() + Duration::from_secs(3);
while std::net::TcpListener::bind(("127.0.0.1", port)).is_err() {
if Instant::now() >= deadline {
ide_core::process::kill_tree_of(pid);
panic!("adapter cleanup left its descendant holding the port");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn stop_detaches_from_an_attached_program() {
let mut child = std::process::Command::new("sleep").arg("60").spawn().unwrap();
let h = harness(Script { debuggee_pid: Some(child.id()), ..Default::default() });
let id = h.start(LaunchConfig { request: "attach".into(), ..config() }).await.unwrap();
h.manager.stop(id).await.unwrap();
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "Stop killed the attached program");
assert!(!h.commands().contains(&"terminate".to_owned()));
assert_eq!(h.request("disconnect")["arguments"]["terminateDebuggee"], false);
}
-20
View File
@@ -151,16 +151,6 @@ fn cases(dir: &Path) -> Vec<Case> {
field: None,
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
},
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
Case {
adapter: "gdb",
source: c.join("main.c"),
loop_line: 13,
after_loop: 15,
function: "parse",
field: None,
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
},
]);
out
}
@@ -240,16 +230,6 @@ fn installed(adapter: &str) -> bool {
"js-debug" => "js-debug-adapter",
"delve" => "dlv",
"java-debug" => return true,
// Only a GDB with the DAP interpreter (14+) counts as installed.
"gdb" => {
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(adapter::parse_gdb_version)
.is_some_and(|(major, _)| major >= 14)
});
}
other => other,
};
adapter::resolve_binary(cmd, Path::new("/")).is_some()
+1 -4
View File
@@ -7,20 +7,17 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
dunce.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
oracledb.workspace = true
oracle.workspace = true
bb8.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
dirs.workspace = true
+87 -376
View File
@@ -12,17 +12,14 @@ use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use sqlx::{
Column, ColumnIndex, Connection, Decode, Executor, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode},
Column, ColumnIndex, Connection, Decode, Executor, MySql, Postgres, Row, SqlSafeStr, Sqlite, TypeInfo, ValueRef,
mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions, MySqlSslMode},
pool::PoolConnection,
postgres::{PgConnectOptions, PgConnection, PgSslMode},
postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode},
sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions},
};
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -50,135 +47,12 @@ pub struct QueryResult {
pub execution_ms: f64,
}
/// Session context belongs to the physical connection, including across pool checkouts.
#[derive(Debug, Clone)]
pub struct Context {
default: Option<String>,
current: Option<String>,
known: bool,
}
impl Context {
pub(crate) fn new(default: Option<String>) -> Self {
Self { current: default.clone(), default, known: true }
}
pub(crate) fn set_current(&mut self, current: Option<String>) {
self.current = current;
}
fn wanted(&self, driver: DbDriver, schema: Option<&str>) -> Option<String> {
schema
.filter(|s| !s.is_empty())
.and_then(|s| crate::sql::use_schema(driver, s))
.or_else(|| self.default.clone())
}
}
macro_rules! sqlx_pool {
($manager:ident, $handle:ident, $options:ty, $connection:ty, $driver:expr, $query:expr) => {
pub struct $manager {
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
}
pub struct $handle {
conn: Option<$connection>,
context: Context,
options: $options,
init: Arc<Vec<String>>,
timeout: Duration,
in_transaction: bool,
}
impl std::ops::Deref for $handle {
type Target = $connection;
fn deref(&self) -> &Self::Target {
self.conn.as_ref().expect("live connection")
}
}
impl std::ops::DerefMut for $handle {
fn deref_mut(&mut self) -> &mut Self::Target {
self.conn.as_mut().expect("live connection")
}
}
impl ManageConnection for $manager {
type Connection = $handle;
type Error = sqlx::Error;
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
tokio::time::timeout(self.timeout, async {
let mut conn = <$connection>::connect_with(&self.options).await?;
let context_sql = |value: Option<String>| {
value.map(|v| {
if $driver == DbDriver::Postgres {
format!("SET search_path TO {v}")
} else {
crate::sql::use_schema($driver, &v).expect("switchable driver")
}
})
};
for sql in self.init.iter() {
sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut conn).await?;
}
let row = sqlx::raw_sql(sqlx::AssertSqlSafe($query.to_owned())).fetch_one(&mut conn).await?;
let context = Context::new(context_sql(row.try_get(0)?));
Ok($handle {
conn: Some(conn),
context,
options: self.options.clone(),
init: self.init.clone(),
timeout: self.timeout,
in_transaction: false,
})
})
.await
.map_err(|_| sqlx::Error::PoolTimedOut)?
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
conn.ping().await
}
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
conn.conn.is_none()
}
}
};
}
sqlx_pool!(PgManager, PgHandle, PgConnectOptions, PgConnection, DbDriver::Postgres, "SHOW search_path");
sqlx_pool!(MysqlManager, MysqlHandle, MySqlConnectOptions, MySqlConnection, DbDriver::Mysql, "SELECT DATABASE()");
pub struct ServerPool<M: ManageConnection> {
inner: Bb8Pool<M>,
closed: AtomicBool,
}
impl<M: ManageConnection> ServerPool<M> {
async fn get(&self) -> IdeResult<bb8::PooledConnection<'static, M>>
where
M::Error: std::fmt::Display,
{
if self.closed.load(Ordering::Relaxed) {
return Err(IdeError::new(ErrorKind::Io, "database connection is closed"));
}
self.inner.get_owned().await.map_err(|e| match e {
bb8::RunError::User(e) => IdeError::new(ErrorKind::Io, e.to_string()),
bb8::RunError::TimedOut => IdeError::new(ErrorKind::Io, "timed out connecting to the database"),
})
}
}
// Keep an empty slot when a connection is discarded after cancellation or rollback failure.
pub struct MssqlConnectionManager(tiberius::Config);
type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;
pub struct MssqlConnection(Option<MssqlClient>, Context);
pub struct MssqlConnection(Option<MssqlClient>);
impl std::ops::Deref for MssqlConnection {
type Target = MssqlClient;
@@ -202,7 +76,7 @@ impl ManageConnection for MssqlConnectionManager {
let tcp = tokio::net::TcpStream::connect(self.0.get_addr()).await?;
tcp.set_nodelay(true)?;
let mut client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
let client = match MssqlClient::connect(self.0.clone(), tcp.compat_write()).await {
// Preserve the single redirect supported by the previous pool adapter.
Err(tiberius::error::Error::Routing { host, port }) => {
let mut config = self.0.clone();
@@ -214,11 +88,7 @@ impl ManageConnection for MssqlConnectionManager {
}
result => result?,
};
let row = client.simple_query("SELECT DB_NAME()").await?.into_row().await?;
let default = row
.and_then(|r| r.get::<&str, _>(0).map(str::to_owned))
.and_then(|db| crate::sql::use_schema(DbDriver::SqlServer, &db));
Ok(MssqlConnection(Some(client), Context::new(default)))
Ok(MssqlConnection(Some(client)))
}
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
@@ -233,13 +103,25 @@ impl ManageConnection for MssqlConnectionManager {
pub enum Pool {
Sqlite(SqlitePool),
Postgres(ServerPool<PgManager>),
Mysql(ServerPool<MysqlManager>),
Postgres(PgPool),
Mysql(MySqlPool),
SqlServer(Bb8Pool<MssqlConnectionManager>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(crate::oracle::OraclePool),
}
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
macro_rules! with_pool {
($pool:expr, $p:ident => $body:expr) => {
match $pool {
Pool::Sqlite($p) => $body,
Pool::Postgres($p) => $body,
Pool::Mysql($p) => $body,
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
}
};
}
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
match &e {
sqlx::Error::Database(d) => {
@@ -417,7 +299,7 @@ fn binary_type(name: &str, text: bool) -> bool {
}
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
match (String::from_utf8(bytes), mode) {
(Ok(s), _) => s,
(Err(e), Cells::Display) => hex(e.as_bytes()),
@@ -556,19 +438,6 @@ macro_rules! backend_id {
}};
}
// Session control, scripts and PostgreSQL set_config can invalidate the tracked context.
fn context_may_change(sql: &str) -> bool {
let text = sql.trim().trim_end_matches(';');
text.contains(';')
|| text.to_ascii_lowercase().contains("set_config")
|| !text.split_whitespace().next().is_some_and(|s| {
matches!(
s.to_ascii_uppercase().as_str(),
"SELECT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP" | "TRUNCATE" | "SHOW" | "EXPLAIN"
)
})
}
fn looks_like_query(sql: &str) -> bool {
let first = sql
.lines()
@@ -594,13 +463,6 @@ fn is_identifier(name: &str) -> bool {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}
/// sqlx's handshake flags for `sql_mode`: with either enabled it sends
/// `SET sql_mode=(SELECT CONCAT(...))`, a non-constant subquery that MySQL-protocol
/// servers like StarRocks reject (#2452), so the StarRocks preset turns both off.
fn mysql_mode_flags(vendor: &str) -> (bool, bool) {
if vendor == "starrocks" { (false, false) } else { (true, true) }
}
/// A MySQL `SET` value: numbers and keywords as they are, anything else quoted.
fn mysql_value(v: &str) -> String {
let keyword = ["ON", "OFF", "TRUE", "FALSE", "DEFAULT"].iter().any(|k| k.eq_ignore_ascii_case(v));
@@ -673,13 +535,7 @@ impl Setup {
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
}
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
(_, "connect_timeout") => {
let seconds = number(&name)?;
if seconds == 0 {
return Err(IdeError::invalid("connect_timeout must be greater than zero"));
}
s.connect_timeout = Some(Duration::from_secs(seconds));
}
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
@@ -806,12 +662,14 @@ impl Pool {
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
let manager = PgManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Postgres(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
let pool = PgPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Postgres(pool))
}
DbDriver::Mysql => {
let mut opts = MySqlConnectOptions::new()
@@ -846,8 +704,6 @@ impl Pool {
if let Some(c) = &setup.collation {
opts = opts.collation(c);
}
let (pipes_as_concat, no_engine_substitution) = mysql_mode_flags(&source.vendor);
opts = opts.pipes_as_concat(pipes_as_concat).no_engine_substitution(no_engine_substitution);
if let Some(n) = setup.statement_cache {
opts = opts.statement_cache_capacity(n);
}
@@ -855,12 +711,14 @@ impl Pool {
if !tz.is_empty() {
opts = opts.timezone(Some(tz.to_owned()));
}
let manager = MysqlManager { options: opts, init: init.clone(), timeout };
// Open once directly so authentication failures retain their database error.
let first = manager.connect().await.map_err(db_err)?;
let pool = Bb8Pool::builder().max_size(MAX).connection_timeout(timeout).build_unchecked(manager);
let _ = pool.add(first);
Ok(Pool::Mysql(ServerPool { inner: pool, closed: AtomicBool::new(false) }))
let pool = MySqlPoolOptions::new()
.max_connections(MAX)
.acquire_timeout(timeout)
.after_connect(after_connect!())
.connect_with(opts)
.await
.map_err(db_err)?;
Ok(Pool::Mysql(pool))
}
DbDriver::SqlServer => {
use tiberius::Config;
@@ -899,9 +757,7 @@ impl Pool {
match self {
Pool::SqlServer(_) | Pool::Oracle(_) => {}
Pool::Clickhouse(p) => p.close(),
Pool::Sqlite(p) => p.close().await,
Pool::Postgres(p) => p.closed.store(true, Ordering::Relaxed),
Pool::Mysql(p) => p.closed.store(true, Ordering::Relaxed),
_ => with_pool!(self, p => p.close().await),
}
}
@@ -910,9 +766,7 @@ impl Pool {
Pool::SqlServer(_) => false,
Pool::Oracle(p) => p.is_closed(),
Pool::Clickhouse(p) => p.is_closed(),
Pool::Sqlite(p) => p.is_closed(),
Pool::Postgres(p) => p.closed.load(Ordering::Relaxed),
Pool::Mysql(p) => p.closed.load(Ordering::Relaxed),
_ => with_pool!(self, p => p.is_closed()),
}
}
@@ -921,9 +775,18 @@ impl Pool {
if let Pool::Clickhouse(p) = self {
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
}
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
let fetched = conn.fetch_rows(sql, max).await;
if let Pool::Oracle(p) = self {
let mut conn = p.get().await?;
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
return Ok(fetched.1);
}
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, 0, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
Ok(fetched?.1)
}
@@ -946,10 +809,7 @@ impl Pool {
return p.execute_from(sql, skip, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = match conn.apply_context(None).await {
Ok(()) => conn.execute_from(self, sql, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -962,10 +822,8 @@ impl Pool {
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
match self {
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
Pool::SqlServer(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::SqlServer(mut conn) = held else { unreachable!() };
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
let result = async {
mssql_command(&mut conn, "BEGIN TRAN").await?;
let mut total = 0;
@@ -988,10 +846,8 @@ impl Pool {
}
result
}
Pool::Oracle(_) => {
let mut held = Held::acquire(self).await?;
held.apply_context(None).await?;
let Held::Oracle(mut conn) = held else { unreachable!() };
Pool::Oracle(p) => {
let mut conn = p.get().await?;
let result = crate::oracle::transaction(&mut conn, statements).await;
if result.is_err() {
// An open transaction must never return to the pool with the connection.
@@ -999,37 +855,21 @@ impl Pool {
}
result
}
_ => {
let mut conn = Held::acquire(self).await?;
conn.apply_context(None).await?;
macro_rules! transaction {
($c:expr) => {{
let mut tx = (&mut **$c).begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone()))
.execute(&mut *tx)
.await
.map_err(db_err)?;
let n = done.rows_affected();
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!(
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
)));
}
total += n;
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}};
_ => with_pool!(self, p => {
let mut tx = p.begin().await.map_err(db_err)?;
let mut total = 0;
for (sql, expect_one) in statements {
let done = sqlx::raw_sql(sqlx::AssertSqlSafe(sql.clone())).execute(&mut *tx).await.map_err(db_err)?;
let n = done.rows_affected();
// MySQL reports 0 for an UPDATE that leaves the row unchanged.
if *expect_one && (n > 1 || (n == 0 && !matches!(self, Pool::Mysql(_)))) {
return Err(IdeError::invalid(format!("{sql}: expected to change 1 row, changed {n}; nothing was saved")));
}
total += n;
}
match &mut conn {
Held::Sqlite(c) => transaction!(c),
Held::Postgres(c) => transaction!(&mut ***c),
Held::Mysql(c) => transaction!(&mut ***c),
_ => unreachable!(),
}
}
tx.commit().await.map_err(db_err)?;
Ok(total)
}),
}
}
}
@@ -1038,19 +878,19 @@ impl Pool {
/// manual-commit console across statements with its transaction open.
pub enum Held {
Sqlite(PoolConnection<Sqlite>),
Postgres(Box<bb8::PooledConnection<'static, PgManager>>),
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
Postgres(PoolConnection<Postgres>),
Mysql(PoolConnection<MySql>),
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
Clickhouse(crate::clickhouse::ClickhousePool),
Oracle(Box<crate::oracle::OracleConnection>),
Oracle(crate::oracle::OracleConnection),
}
impl Held {
pub async fn acquire(pool: &Pool) -> IdeResult<Self> {
Ok(match pool {
Pool::Sqlite(p) => Held::Sqlite(p.acquire().await.map_err(db_err)?),
Pool::Postgres(p) => Held::Postgres(Box::new(p.get().await?)),
Pool::Mysql(p) => Held::Mysql(Box::new(p.get().await?)),
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
Pool::Clickhouse(p) => {
if p.is_closed() {
@@ -1058,125 +898,12 @@ impl Held {
}
Held::Clickhouse(p.clone())
}
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
Pool::Oracle(p) => Held::Oracle(p.get().await?),
})
}
fn context(&mut self) -> Option<&mut Context> {
match self {
Held::Postgres(c) => Some(&mut c.context),
Held::Mysql(c) => Some(&mut c.context),
Held::SqlServer(c) => Some(&mut c.1),
Held::Oracle(c) => Some(c.context()),
_ => None,
}
}
fn invalidate_context(&mut self) {
if let Some(context) = self.context() {
context.known = false;
}
}
fn before_statement(&mut self, sql: &str) {
let control = sql.trim().trim_end_matches(';').trim().to_ascii_uppercase();
if let Held::Mysql(c) = self {
if matches!(control.as_str(), "BEGIN" | "START TRANSACTION") {
c.in_transaction = true;
return;
}
if matches!(control.as_str(), "COMMIT" | "ROLLBACK") {
return;
}
}
if context_may_change(sql) {
self.invalidate_context();
}
}
fn after_statement(&mut self, sql: &str) {
if let Held::Mysql(c) = self
&& matches!(sql.trim().trim_end_matches(';').trim().to_ascii_uppercase().as_str(), "COMMIT" | "ROLLBACK")
{
c.in_transaction = false;
}
}
pub async fn apply_context(&mut self, schema: Option<&str>) -> IdeResult<()> {
let driver = match self {
Held::Postgres(_) => DbDriver::Postgres,
Held::Mysql(_) => DbDriver::Mysql,
Held::SqlServer(_) => DbDriver::SqlServer,
Held::Oracle(_) => DbDriver::Oracle,
_ => return Ok(()),
};
let context = self.context().expect("switchable connection");
let wanted = context.wanted(driver, schema);
if context.known && context.current == wanted {
return Ok(());
}
context.known = false;
if let Some(sql) = &wanted {
self.raw_command(sql).await?;
} else {
// MySQL cannot USE an absent database. Re-open with the original options.
macro_rules! reopen {
($c:expr, $manager:ident) => {{
let manager = $manager { options: $c.options.clone(), init: $c.init.clone(), timeout: $c.timeout };
if $c.in_transaction {
return Err(IdeError::invalid("commit or roll back before clearing the selected database"));
}
let fresh = manager.connect().await.map_err(db_err)?;
if let Some(raw) = $c.conn.take() {
let _ = raw.close().await;
}
***$c = fresh;
}};
}
match self {
Held::Mysql(c) => reopen!(c, MysqlManager),
Held::Postgres(c) => reopen!(c, PgManager),
_ => unreachable!("server context has a default"),
}
}
let context = self.context().expect("switchable connection");
context.current = wanted;
context.known = true;
Ok(())
}
/// Restore before a manual session returns its physical connection to the pool.
pub async fn restore_default(&mut self) -> IdeResult<()> {
self.apply_context(None).await
}
async fn fetch_rows(&mut self, sql: &str, max: usize) -> IdeResult<Fetched> {
self.before_statement(sql);
let result = match self {
Held::Sqlite(c) => fetch!(&mut **c, sql, max, Cells::Text),
Held::Postgres(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::Mysql(c) => fetch!(&mut ****c, sql, max, Cells::Text),
Held::SqlServer(c) => mssql_fetch(c, sql, 0, max).await,
Held::Oracle(c) => crate::oracle::run(c, sql, 0, max, Cells::Text, &CancellationToken::new()).await,
Held::Clickhouse(_) => unreachable!(),
};
if result.is_ok() {
self.after_statement(sql);
}
result
}
/// Runs a statement without results (`BEGIN`, `COMMIT`, `ROLLBACK`).
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
self.before_statement(sql);
let result = self.raw_command(sql).await;
if result.is_ok() {
self.after_statement(sql);
}
result
}
async fn raw_command(&mut self, sql: &str) -> IdeResult<()> {
match self {
Held::SqlServer(c) => mssql_command(c, sql).await,
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
@@ -1191,8 +918,8 @@ impl Held {
let sql = sqlx::AssertSqlSafe(sql.to_owned());
match other {
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut ****c).await.map(drop),
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
}
.map_err(db_err)
@@ -1205,15 +932,11 @@ impl Held {
pub async fn discard(self) {
match self {
Held::Sqlite(c) => drop(c.detach()),
Held::Postgres(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
Held::Postgres(c) => {
let _ = c.detach().close().await;
}
Held::Mysql(mut c) => {
if let Some(raw) = c.conn.take() {
let _ = raw.close().await;
}
Held::Mysql(c) => {
let _ = c.detach().close().await;
}
Held::SqlServer(mut c) => {
c.0.take();
@@ -1248,7 +971,6 @@ impl Held {
if let Held::Clickhouse(c) = self {
return c.execute_from(sql, skip, max_rows, cancel).await;
}
self.before_statement(sql);
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
macro_rules! run {
@@ -1281,9 +1003,9 @@ impl Held {
let fetched: Fetched = match self {
Held::Sqlite(c) => run!(c, None::<&str>, |_: String| String::new()),
Held::Postgres(c) => {
run!(&mut ***c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
run!(c, Some("SELECT pg_backend_pid()"), |b: String| format!("SELECT pg_cancel_backend({b})"))
}
Held::Mysql(c) => run!(&mut ***c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::Mysql(c) => run!(c, Some("SELECT CONNECTION_ID()"), |b: String| format!("KILL QUERY {b}")),
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
@@ -1294,7 +1016,6 @@ impl Held {
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
Held::Clickhouse(_) => unreachable!(),
};
self.after_statement(sql);
let (columns, rows, affected, has_more, first) = fetched;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok(QueryResult {
@@ -1334,7 +1055,7 @@ mod tests {
#[test]
fn discarded_sqlserver_connections_are_broken() {
let manager = MssqlConnectionManager(tiberius::Config::new());
assert!(manager.has_broken(&mut MssqlConnection(None, Context::new(None))));
assert!(manager.has_broken(&mut MssqlConnection(None)));
}
#[tokio::test]
@@ -1470,19 +1191,9 @@ mod tests {
assert_eq!(setup.charset.as_deref(), Some("utf8mb4"));
assert_eq!(setup.connect_timeout, Some(Duration::from_secs(5)));
assert!(Setup::of(&DataSource { properties: vec![prop("a; drop", "1")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "0")], ..s.clone() }).is_err());
assert!(Setup::of(&DataSource { properties: vec![prop("connect_timeout", "soon")], ..s }).is_err());
}
#[test]
fn starrocks_disables_the_non_constant_sql_mode_handshake() {
// MySQL and its other wire-compatible presets keep sqlx's defaults.
for vendor in ["", "mysql", "mariadb", "tidb"] {
assert_eq!(mysql_mode_flags(vendor), (true, true), "{vendor}");
}
assert_eq!(mysql_mode_flags("starrocks"), (false, false));
}
#[test]
fn sqlite_properties_become_pragmas() {
let s = DataSource { properties: vec![prop("cache_size", "-2000")], ..Default::default() };
+26 -116
View File
@@ -7,7 +7,6 @@ pub mod conn;
pub mod history;
mod oracle;
pub mod plan;
pub mod plugin;
pub mod secrets;
pub mod source;
pub mod split;
@@ -57,7 +56,6 @@ impl DbTableKind {
pub enum DbObjectKind {
Function,
Procedure,
Package,
Trigger,
Sequence,
}
@@ -93,21 +91,12 @@ pub struct SequenceInfo {
pub data_type: String,
}
/// An Oracle package (its procedures and functions are not listed separately).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPackageInfo")]
pub struct PackageInfo {
pub name: String,
}
/// A schema's routines (procedures and functions), packages and sequences.
/// A schema's routines and sequences (the tree's `routines` / `sequences` folders).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSchemaObjects")]
pub struct SchemaObjects {
pub routines: Vec<RoutineInfo>,
pub packages: Vec<PackageInfo>,
pub sequences: Vec<SequenceInfo>,
}
@@ -306,7 +295,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
let pool = Arc::downgrade(pool);
tokio::spawn(async move {
let mut tick = tokio::time::interval(every);
@@ -326,7 +315,7 @@ pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
async fn end_session(session: &Session) {
let held = session.lock().await.take();
if let Some(mut conn) = held
&& (conn.command("ROLLBACK").await.is_err() || conn.restore_default().await.is_err())
&& conn.command("ROLLBACK").await.is_err()
{
conn.discard().await;
}
@@ -392,9 +381,7 @@ impl DbManager {
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
store
}
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
@@ -425,28 +412,19 @@ impl DbManager {
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
}
let old = {
let _guard = plugin::EDIT.lock();
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
None if !global.is_empty() => {
return Err(IdeError::invalid("global data sources are not available here"));
}
None => {}
}
source::save(&self.root, &project)?;
old
};
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
@@ -726,15 +704,8 @@ impl DbManager {
let introspection = self.source(id)?.introspection;
let system = introspection.show_system;
let pool = self.pool(id).await?;
if pool.driver() == DbDriver::SqlServer {
let first =
|rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT DB_NAME()", 1).await?).next().unwrap_or_default();
let names = first(pool.rows(sql::sqlserver_databases(), MAX_META_ROWS).await?).collect();
return Ok(Databases { current, names });
}
if pool.driver() != DbDriver::Postgres {
return Err(IdeError::invalid("this data source does not list databases"));
return Err(IdeError::invalid("only PostgreSQL data sources list databases"));
}
let first = |rows: Vec<Vec<Option<String>>>| rows.into_iter().filter_map(|r| r.into_iter().next().flatten());
let current = first(pool.rows("SELECT current_database()", 1).await?).next().unwrap_or_default();
@@ -863,14 +834,6 @@ impl DbManager {
})
.collect();
}
if let Some(q) = sql::packages(driver, schema) {
out.packages = pool
.rows(&q, MAX_META_ROWS)
.await?
.into_iter()
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
.collect();
}
if let Some(q) = sql::sequences(driver, schema) {
out.sequences = pool
.rows(&q, MAX_META_ROWS)
@@ -888,29 +851,9 @@ impl DbManager {
Ok(out)
}
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
/// The definition of a routine (every overload), trigger or sequence.
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
let pool = self.pool(id).await?;
if kind == DbObjectKind::Package {
let mut defs: Vec<String> = Vec::new();
for body in [false, true] {
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
match pool.rows(&q, MAX_META_ROWS).await {
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
// A spec-only package has no body: the spec alone still opens.
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
Err(e) => return Err(e),
}
}
if defs.is_empty() {
return Err(IdeError::not_found(format!("{schema}.{name}")));
}
return Ok(defs
.iter()
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
.collect::<Vec<_>>()
.join("\n"));
}
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
let defs: Vec<String> = pool
@@ -1095,13 +1038,11 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
self.execute_from(id, statement, 0, max_rows, cancel, None).await
self.execute_from(id, statement, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping
/// them: a later page of a result, where only that page is decoded and returned.
/// `schema` is a query console's active context: the connection that runs the
/// statement is put in it first, so unqualified names resolve there.
pub async fn execute_from(
&self,
id: &str,
@@ -1109,9 +1050,8 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let r = self.run(id, statement, skip, max_rows, cancel, schema).await;
let r = self.run(id, statement, skip, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1143,15 +1083,8 @@ impl DbManager {
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The execution plan of `statement` (explained, not run), in the console's
/// active `schema` when it has one.
pub async fn explain(
&self,
id: &str,
statement: &str,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<Vec<PlanNode>> {
/// The execution plan of `statement` (explained, not run).
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
let driver = self.pool(id).await?.driver();
if driver == DbDriver::Oracle {
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
@@ -1169,16 +1102,14 @@ impl DbManager {
0,
MAX_META_ROWS,
cancel,
schema,
)
.await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel, None).await?;
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
let nodes = plan::parse(driver, &result);
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel, None).await;
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
return nodes;
}
let result =
self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel, schema).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1189,25 +1120,11 @@ impl DbManager {
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
schema: Option<&str>,
) -> IdeResult<QueryResult> {
let pool = self.pool(id).await?;
let session = self.sessions.lock().get(id).cloned();
let Some(session) = session else {
if schema.is_none() {
return pool.execute_from(statement, skip, max_rows, cancel).await;
}
// Like `Pool::execute_from`, with the schema applied to the connection first.
let mut conn = Held::acquire(&pool).await?;
let r = match conn.apply_context(schema).await {
Ok(()) => conn.execute_from(&pool, statement, skip, max_rows, cancel).await,
Err(e) => Err(e),
};
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
}
return r;
return pool.execute_from(statement, skip, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1215,12 +1132,10 @@ impl DbManager {
let mut held = session.lock().await;
if held.is_none() {
let mut conn = Held::acquire(&pool).await?;
conn.apply_context(schema).await?;
conn.command(conn::begin_statement(pool.driver())).await?;
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
conn.apply_context(schema).await?;
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
@@ -1256,12 +1171,7 @@ impl DbManager {
let Some(session) = session else { return Ok(()) };
let mut held = session.lock().await;
let Some(mut conn) = held.take() else { return Ok(()) };
let result = async {
conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await?;
conn.restore_default().await
}
.await;
match result {
match conn.command(if commit { "COMMIT" } else { "ROLLBACK" }).await {
Ok(()) => Ok(()),
Err(e) => {
conn.discard().await;
File diff suppressed because it is too large. Load diff
-271
View File
@@ -1,271 +0,0 @@
//! Ownership checks and surgical updates of plugin-created project sources.
use crate::{DataSource, DbManager, source};
use ide_core::{IdeError, IdeResult, project_dir};
use std::path::Path;
// All DbManagers of the same root share this file. Never hold this across an await.
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
/// Host-owned connection id, unambiguous and safe for console paths.
pub fn source_id(plugin: &str, id: &str) -> String {
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
}
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
source.id == source_id(plugin, id)
&& source.plugin_owner.as_deref() == Some(plugin)
&& source.plugin_source_id.as_deref() == Some(id)
&& !source.global
}
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
let path = project_dir::path(root, "data-sources.toml");
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
&& root.exists()
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
{
return Err(IdeError::invalid("data sources file is outside the project"));
}
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let old = source::load(root)?;
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
while end < text.len() {
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
if !text[end..next].trim().is_empty() {
break;
}
end = next;
}
out.push(start..end);
}
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
if !table.is_implicit()
&& let Some(span) = table.span()
{
line_span(text, span, out);
}
for (_, item) in table.iter() {
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
line_span(text, span, out);
}
if let Some(table) = item.as_table() {
spans(table, text, out);
}
if let Some(tables) = item.as_array_of_tables() {
for table in tables.iter() {
spans(table, text, out);
}
}
}
}
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
}
let mut ranges = vec![];
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
for (table, source) in tables.iter().zip(&old) {
if remove(source) {
spans(table, &text, &mut ranges);
}
}
}
ranges.sort_by_key(|r| r.start);
let mut merged: Vec<std::ops::Range<usize>> = vec![];
for range in ranges {
if let Some(last) = merged.last_mut()
&& range.start <= last.end
{
last.end = last.end.max(range.end);
continue;
}
merged.push(range);
}
let mut output = text.clone();
for range in merged.into_iter().rev() {
output.replace_range(range, "");
}
if let Some(add) = add {
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
value.as_table_mut().unwrap().remove("global");
let value =
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
}
if !removed.is_empty() || add.is_some() {
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&tmp, output)?;
if let Err(e) = std::fs::rename(&tmp, &path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
}
Ok(removed)
}
/// Removes only this plugin's sources, including when their project is closed at uninstall.
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
let _guard = EDIT.lock();
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
}
impl DbManager {
/// Lists private source ids without exposing any connection settings.
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
Ok(source::load(&self.root)?
.into_iter()
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
.filter_map(|s| s.plugin_source_id)
.collect())
}
/// Checks the stored owner, even if a user-created source uses the host's id format.
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
source::load(&self.root)?
.into_iter()
.find(|s| owned(s, plugin, id))
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
}
/// Replaces only an owned source and disconnects its old pools.
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
source.id = source_id(plugin, id);
source.plugin_owner = Some(plugin.into());
source.plugin_source_id = Some(id.into());
source.global = false;
{
let _guard = EDIT.lock();
let all = self.sources()?;
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
return Err(IdeError::not_found("no owned data source with this id"));
}
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
}
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
}
self.disconnect_source(&source.id).await;
Ok(())
}
/// Deletes an owned source and all its saved/session credentials.
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
let source = {
let _guard = EDIT.lock();
let source = self.plugin_source(plugin, id)?;
update(&self.root, |s| owned(s, plugin, id), None)?;
source
};
self.forget_plugin_source(&source.id).await;
Ok(())
}
/// Completes cleanup after removal from the file.
pub async fn forget_plugin_source(&self, id: &str) {
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
self.session_passwords.lock().remove(&key);
self.saved_passwords.delete(&key);
}
self.disconnect_source(id).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DbDriver, secrets::SecretStore};
#[tokio::test]
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
let file = root.join(".openidea/data-sources.toml");
std::fs::write(&file, original).unwrap();
let secrets = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
secrets.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let settings =
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
assert!(db.plugin_source("a", "user").is_err());
assert!(db.remove_plugin_source("a", "user").await.is_err());
let source = db.plugin_source("a", "local").unwrap();
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
let store = SecretStore::new(secrets, &root);
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
db.remove_plugin_source("a", "local").await.unwrap();
assert!(store.get(&source.id).is_none());
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
remove_all(&root, "b").unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
}
#[tokio::test]
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let global = tmp.path().join("global");
let passwords = tmp.path().join("passwords.json");
let db = DbManager::new(
root.clone(),
passwords.clone(),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
)
.with_global_sources(global.clone());
db.upsert_plugin_source(
"plugin",
"local",
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
)
.await
.unwrap();
let owned = db.plugin_source("plugin", "local").unwrap();
source::save_global(
&global,
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
)
.unwrap();
let global_store = SecretStore::new(passwords.clone(), &global);
global_store.set(&owned.id, "user-secret").unwrap();
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
db.remove_plugin_source("plugin", "local").await.unwrap();
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
}
#[tokio::test]
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
let tmp = tempfile::tempdir().unwrap();
let db = DbManager::new(
tmp.path().join("project"),
tmp.path().join("passwords.json"),
tmp.path().join("history.json"),
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
);
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
db.save_sources(vec![source.clone()]).await.unwrap();
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
assert_eq!(db.sources().unwrap(), [source]);
}
}
+4 -11
View File
@@ -20,7 +20,7 @@ pub enum DbDriver {
/// Microsoft SQL Server.
SqlServer,
Clickhouse,
/// Oracle Database (pure-Rust thin driver, no client library).
/// Oracle Database (via Oracle Instant Client).
Oracle,
}
@@ -88,6 +88,8 @@ pub struct DbOptions {
pub auto_disconnect_secs: Option<u32>,
/// PostgreSQL: show only the configured database, its schemas right under the data source.
pub single_database: bool,
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
pub instant_client: String,
}
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
@@ -188,12 +190,6 @@ pub struct DataSource {
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
/// Plugin id owning this source; absent for user-managed sources.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_owner: Option<String>,
/// Id private to the owner, never used as a connection target.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_source_id: Option<String>,
}
impl Default for DataSource {
@@ -223,8 +219,6 @@ impl Default for DataSource {
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
plugin_owner: None,
plugin_source_id: None,
}
}
}
@@ -268,7 +262,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
match scheme.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pgsql" | "redshift" | "cockroachdb" | "cockroach" | "yugabytedb"
| "timescaledb" | "greenplum" => Some(DbDriver::Postgres),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" | "starrocks" => Some(DbDriver::Mysql),
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
@@ -557,7 +551,6 @@ mod tests {
assert_eq!((u.host.as_str(), u.port, u.user.as_str()), ("::1", None, "root"));
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("starrocks://h:9030").unwrap().driver, Some(DbDriver::Mysql));
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
assert_eq!(u.driver, Some(DbDriver::Oracle));
-70
View File
@@ -63,11 +63,6 @@ pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
"XS$NULL",
];
/// Databases available to this login; offline/restoring databases cannot be selected.
pub fn sqlserver_databases() -> &'static str {
"SELECT name FROM sys.databases WHERE state = 0 AND HAS_DBACCESS(name) = 1 ORDER BY name"
}
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
match driver {
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
@@ -401,18 +396,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
}
}
/// The statement that makes unqualified names resolve in `schema` on the
/// connection that runs it (a query console's active schema; `None`: the driver
/// has no schema to switch — SQLite's `main`, ClickHouse's `USE` differs).
pub fn use_schema(driver: DbDriver, schema: &str) -> Option<String> {
match driver {
DbDriver::Sqlite | DbDriver::Clickhouse => None,
DbDriver::Postgres => Some(format!("SET search_path TO {}", quote_ident(driver, schema))),
DbDriver::Mysql | DbDriver::SqlServer => Some(format!("USE {}", quote_ident(driver, schema))),
DbDriver::Oracle => Some(format!("ALTER SESSION SET CURRENT_SCHEMA = {}", quote_ident(driver, schema))),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbSortKey")]
@@ -664,32 +647,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 +946,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 +967,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 +984,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!(
@@ -1050,20 +994,6 @@ mod tests {
assert!(object_ddl(DbDriver::Postgres, "s", K::Sequence, "q").unwrap().0.contains("sequencename = 'q'"));
}
#[test]
fn schema_switch_statement_per_dialect() {
assert_eq!(use_schema(DbDriver::Postgres, "app data"), Some("SET search_path TO \"app data\"".into()));
assert_eq!(use_schema(DbDriver::Mysql, "a`b"), Some("USE `a``b`".into()));
assert_eq!(use_schema(DbDriver::SqlServer, "app] data"), Some("USE [app]] data]".into()));
assert_eq!(use_schema(DbDriver::Postgres, "a\"b"), Some("SET search_path TO \"a\"\"b\"".into()));
assert_eq!(use_schema(DbDriver::Oracle, "a\"b"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"a\"\"b\"".into()));
assert!(sqlserver_databases().contains("state = 0 AND HAS_DBACCESS(name) = 1"));
assert_eq!(use_schema(DbDriver::Oracle, "hr"), Some("ALTER SESSION SET CURRENT_SCHEMA = \"hr\"".into()));
// Single-schema (SQLite) and not-yet-supported drivers: no dropdown, no switch.
assert_eq!(use_schema(DbDriver::Sqlite, "main"), None);
assert_eq!(use_schema(DbDriver::Clickhouse, "default"), None);
}
#[test]
fn postgres_ddl() {
let cols = [
+1 -1
View File
@@ -103,7 +103,7 @@ async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()>
"completion columns differ from catalog",
)?;
}
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel, None).await?;
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
check(!plan.is_empty(), "query plan missing")?;
let value = r"path\O'Brien";
let result = db
-98
View File
@@ -85,101 +85,3 @@ async fn metadata_names_are_text_whatever_the_column_collation() -> IdeResult<()
let raw = manager(&tmp, "SET character_set_results = binary").await.unwrap();
assert_metadata_is_text(&raw).await
}
// A console's active schema: the connection that runs each autocommit statement
// is put in it first, so unqualified names resolve there (and not elsewhere).
// Its own schema names: tests of this file share one server but run in parallel.
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
const A: &str = "oi_ctx_meta";
const B: &str = "oi_ctx_data";
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp, "").await else { return Ok(()) };
let c = CancellationToken::new();
for s in [A, B] {
for stmt in [
format!("DROP DATABASE IF EXISTS `{s}`"),
format!("CREATE DATABASE `{s}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"),
format!("CREATE TABLE `{s}`.`用户` (id INT PRIMARY KEY, name VARCHAR(20))"),
format!("INSERT INTO `{s}`.`用户` VALUES (1, '{s}')"),
] {
db.execute("m", &stmt, 10, &c).await.unwrap();
}
}
// The source connects with no default database; the console's pick decides.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
// Both schemas hold `用户`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, name) in [(A, A), (B, B)] {
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(name.into())]], "of {schema}");
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![None]]);
assert_eq!(db.completion("m").await?.default_schema, "");
}
}
// A statement without a selection returns to the source's absent default.
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, None).await?;
assert_eq!(r.rows, vec![vec![None]]);
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("m", true).await?;
// Starting a no-database manual session must keep its transaction open.
db.execute("m", &format!("INSERT INTO `{A}`.`用户` VALUES (2, 'rollback')"), 1, &c).await?;
db.finish_transaction("m", false).await?;
assert!(db.execute("m", &format!("SELECT name FROM `{A}`.`用户` WHERE id = 2"), 1, &c).await?.rows.is_empty());
let r = db.execute_from("m", "SELECT name FROM 用户", 0, 1, &c, Some(A)).await?;
assert_eq!(r.rows, vec![vec![Some(A.into())]]);
let r = db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?;
assert_eq!(r.rows, vec![vec![Some(B.into())]]);
assert!(db.execute("m", "SELECT DATABASE()", 1, &c).await.is_err());
// The rejected clear keeps the transaction and selection intact.
assert_eq!(db.execute_from("m", "SELECT DATABASE()", 0, 1, &c, Some(B)).await?.rows, vec![vec![Some(B.into())]]);
db.finish_transaction("m", true).await?;
for commit in [false, true] {
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.finish_transaction("m", commit).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
}
db.execute_from("m", "SELECT 1", 0, 1, &c, Some(A)).await?;
db.set_manual_commit("m", false).await?;
db.execute("m", &format!("USE `{B}`"), 1, &c).await?;
let r = db.execute("m", "SELECT DATABASE()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![None]]);
// An unknown schema fails the statement instead of running it somewhere else.
let error = db.execute_from("m", "SELECT 1", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.contains("oi_missing"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_configured_database() -> IdeResult<()> {
use ide_db::{Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Mysql,
host,
port: Some(port),
user,
database: "mysql".into(),
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT CONNECTION_ID()", 1, &c).await?.rows;
for schema in ["information_schema", "mysql", "information_schema"] {
conn.apply_context(Some(schema)).await?;
assert_eq!(conn.execute(&pool, "SELECT DATABASE()", 1, &c).await?.rows, vec![vec![Some(schema.into())]]);
}
drop(conn);
assert_eq!(pool.rows("SELECT DATABASE()", 1).await?, vec![vec![Some("mysql".into())]]);
assert_eq!(pool.execute("SELECT CONNECTION_ID()", 1, &c).await?.rows, backend);
Ok(())
}
-135
View File
@@ -1,135 +0,0 @@
//! The active schema context against a real PostgreSQL server, skipped unless
//! `OPENIDEA_TEST_POSTGRES=host:port:user:password` names a throwaway server.
use ide_core::{CancellationToken, IdeResult};
use ide_db::{DataSource, DbDriver, DbManager};
fn server() -> Option<(String, u16, String, String)> {
let v = std::env::var("OPENIDEA_TEST_POSTGRES").ok()?;
let mut it = v.splitn(4, ':');
Some((it.next()?.into(), it.next()?.parse().ok()?, it.next()?.into(), it.next().unwrap_or("").into()))
}
async fn manager(tmp: &tempfile::TempDir) -> Option<DbManager> {
let (host, port, user, password) = server()?;
let root = tmp.path().to_path_buf();
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
db.save_sources(vec![DataSource {
id: "p".into(),
name: "postgres".into(),
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
..Default::default()
}])
.await
.unwrap();
db.set_password("p", Some(password), false).await.unwrap();
Some(db)
}
async fn create_schemas(db: &DbManager) {
let c = CancellationToken::new();
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
for stmt in [
format!("DROP SCHEMA IF EXISTS \"{schema}\" CASCADE"),
format!("CREATE SCHEMA \"{schema}\""),
format!("CREATE TABLE \"{schema}\".items (id INT PRIMARY KEY, tag TEXT)"),
format!("INSERT INTO \"{schema}\".items VALUES (1, '{tag}')"),
] {
db.execute("p", &stmt, 10, &c).await.unwrap();
}
}
}
#[tokio::test]
async fn active_schema_context_drives_the_executing_connection() -> IdeResult<()> {
let tmp = tempfile::tempdir().unwrap();
let Some(db) = manager(&tmp).await else { return Ok(()) };
create_schemas(&db).await;
let c = CancellationToken::new();
// The connection default stays untouched before any console picks a schema.
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert!(r.rows[0][0].as_deref().unwrap_or("").contains("public"), "{:?}", r.rows);
// Both schemas hold `items`; the context resolves the unqualified name in its own,
// on every pooled connection the autocommit statements may land on.
for _ in 0..6 {
for (schema, tag) in [("oi_a", "from a"), ("oi_b", "from b")] {
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some(schema)).await?;
assert_eq!(r.rows, vec![vec![Some(tag.into())]], "of {schema}");
let r = db.execute_from("p", "SHOW search_path", 0, 1, &c, Some("")).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
assert_eq!(db.completion("p").await?.default_schema, "public");
}
}
// A manual-commit console switches schemas on its session connection.
db.set_manual_commit("p", true).await?;
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_a")).await?;
assert_eq!(r.rows, vec![vec![Some("from a".into())]]);
let r = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_b")).await?;
assert_eq!(r.rows, vec![vec![Some("from b".into())]]);
db.finish_transaction("p", true).await?;
for commit in [false, true] {
db.execute_from("p", "SELECT 1", 0, 1, &c, Some("oi_a")).await?;
db.finish_transaction("p", commit).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
}
db.set_manual_commit("p", false).await?;
db.execute("p", "SET search_path TO oi_b", 1, &c).await?;
let r = db.execute("p", "SHOW search_path", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some("\"$user\", public".into())]]);
// An unknown schema never resolves the statement's tables (`SET search_path`
// itself does not validate names; resolution against it does).
let error = db.execute_from("p", "SELECT tag FROM items", 0, 1, &c, Some("oi_missing")).await.unwrap_err();
assert!(error.message.to_lowercase().contains("does not exist"), "{}", error.message);
Ok(())
}
#[tokio::test]
async fn a_reused_physical_connection_restores_the_full_default_path() -> IdeResult<()> {
use ide_db::{DbOptions, Held, Pool};
let Some((host, port, user, password)) = server() else { return Ok(()) };
let source = DataSource {
driver: DbDriver::Postgres,
host,
port: Some(port),
user,
options: DbOptions { default_schema: "public, pg_catalog".into(), ..Default::default() },
..Default::default()
};
let pool = Pool::connect(&source, Some(&password), None).await?;
let c = CancellationToken::new();
// Occupy the other three slots: every operation below must reuse the fourth.
let _other = [Held::acquire(&pool).await?, Held::acquire(&pool).await?, Held::acquire(&pool).await?];
let mut conn = Held::acquire(&pool).await?;
let backend = conn.execute(&pool, "SELECT pg_backend_pid()", 1, &c).await?.rows;
for schema in ["pg_catalog", "public", "pg_catalog"] {
conn.apply_context(Some(schema)).await?;
let r = conn.execute(&pool, "SELECT current_schema()", 1, &c).await?;
assert_eq!(r.rows, vec![vec![Some(schema.into())]]);
}
conn.apply_context(Some("public")).await?;
conn.execute(&pool, "SELECT set_config('search_path', 'pg_catalog', false)", 1, &c).await?;
conn.apply_context(Some("public")).await?;
assert_eq!(conn.execute(&pool, "SELECT current_schema()", 1, &c).await?.rows, vec![vec![Some("public".into())]]);
drop(conn);
assert_eq!(pool.rows("SHOW search_path", 1).await?, vec![vec![Some("public, pg_catalog".into())]]);
assert_eq!(pool.execute("SELECT pg_backend_pid()", 1, &c).await?.rows, backend);
let mut conn = Held::acquire(&pool).await?;
conn.command("BEGIN").await?;
conn.apply_context(Some("pg_catalog")).await?;
conn.command("ROLLBACK").await?;
conn.restore_default().await?;
assert_eq!(
conn.execute(&pool, "SHOW search_path", 1, &c).await?.rows,
vec![vec![Some("public, pg_catalog".into())]]
);
Ok(())
}
+5 -5
View File
@@ -132,16 +132,16 @@ async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
let sql = "SELECT id FROM users ORDER BY id";
let ids = |r: &QueryResult| r.rows.iter().map(|row| row[0].clone().unwrap()).collect::<Vec<_>>();
let second = db.execute_from("s1", sql, 1, 1, &c, None).await?;
let second = db.execute_from("s1", sql, 1, 1, &c).await?;
assert_eq!(ids(&second), vec!["2"]);
assert!(second.has_more);
assert_eq!(second.columns.len(), 1);
let last = db.execute_from("s1", sql, 2, 5, &c, None).await?;
let last = db.execute_from("s1", sql, 2, 5, &c).await?;
assert_eq!(ids(&last), vec!["3"]);
assert!(!last.has_more);
let past = db.execute_from("s1", sql, 9, 5, &c, None).await?;
let past = db.execute_from("s1", sql, 9, 5, &c).await?;
assert!(past.rows.is_empty() && !past.has_more);
assert_eq!(past.columns.len(), 1, "columns of a page past the end come from the prepared statement");
Ok(())
@@ -276,11 +276,11 @@ async fn ddl_of_tables_and_views() -> IdeResult<()> {
async fn explains_queries_without_running_them() -> IdeResult<()> {
let (_tmp, db) = setup().await;
let c = CancellationToken::new();
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c, None).await?;
let plan = db.explain("s1", "SELECT * FROM users WHERE id > 1 ORDER BY note;", &c).await?;
let ops: Vec<_> = plan.iter().map(|n| n.operation.as_str()).collect();
assert_eq!(ops, vec!["Index Scan", "Order By"]);
assert!(plan[0].params.starts_with("table: users;"), "{}", plan[0].params);
db.explain("s1", "DELETE FROM users", &c, None).await?;
db.explain("s1", "DELETE FROM users", &c).await?;
assert_eq!(count(&db).await, "3");
Ok(())
}
+7 -75
View File
@@ -253,32 +253,6 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
@@ -469,7 +443,7 @@ pub fn compose_op(
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
@@ -477,31 +451,17 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => default_daemon_url(
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|name| context_host(&DockerEndpoint::Default, name),
)?,
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
fn default_daemon_url(
context: Option<&str>,
host: Option<&str>,
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
) -> IdeResult<String> {
let context = context.filter(|v| !v.is_empty());
if context.is_some() {
return lookup(context);
}
match host.filter(|v| !v.is_empty()) {
Some(host) => Ok(host.to_owned()),
None => lookup(None),
}
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
@@ -546,34 +506,6 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
mod local_tests {
use super::*;
#[test]
fn the_selected_context_overrides_docker_host() {
let local = "unix:///var/run/docker.sock";
let remote = "ssh://me@build";
for host in [None, Some(""), Some(local)] {
let url = default_daemon_url(Some("build"), host, |name| {
assert_eq!(name, Some("build"));
Ok(remote.into())
})
.unwrap();
assert!(!daemon_url_is_local(&url));
}
for context in [None, Some("")] {
assert_eq!(
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
local
);
assert_eq!(
default_daemon_url(context, None, |name| {
assert_eq!(name, None);
Ok(remote.into())
})
.unwrap(),
remote
);
}
}
#[test]
fn local_daemon_urls() {
for url in [
+19 -181
View File
@@ -41,15 +41,6 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -89,10 +80,6 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -103,46 +90,6 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -239,20 +186,17 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -268,13 +212,10 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -317,23 +258,17 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -348,121 +283,24 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+1 -44
View File
@@ -1,9 +1,6 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -25,16 +22,11 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -52,44 +44,9 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
-1
View File
@@ -18,7 +18,6 @@ semver = "1"
blake3.workspace = true
zip.workspace = true
base64.workspace = true
dunce.workspace = true
ed25519-dalek = "3"
[dev-dependencies]
+17 -243
View File
@@ -18,40 +18,24 @@ pub enum Capability {
EditorRead,
/// Edit the focused editor (versioned edits).
EditorWrite,
/// Create and replace files inside the project root.
ProjectWrite,
/// Run arbitrary IDE actions and other plugins' commands.
IdeCommands,
/// Manage only its own project database sources and write their credentials.
DatabaseSources,
VcsRead,
VcsWrite,
/// The plugin's own key-value store and data directory.
Storage,
/// The plugin's own secrets (kept in the client's OS keychain).
Secrets,
/// Workbench events (files, focus, VCS), not keystrokes.
/// Workbench events (file opened, saved, focus…), not keystrokes.
Events,
/// Keystrokes and editor input events.
InputEvents,
/// The system clipboard, from the plugin's web views.
Clipboard,
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
/// HTTP(S) to one host (`*.example.com` matches subdomains).
Net(String),
/// Ask the user when the first request goes to an HTTPS (or loopback) host
/// the manifest does not list; each answer applies to one host. Plain
/// HTTP elsewhere always needs `net.http:<host>`.
NetAsk,
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
Ipc(String),
/// Run one external program. Full code execution: never fine-grained.
Process(String),
/// Read files outside the project under one path.
FsRead(String),
/// Write files outside the project under one path.
FsWrite(String),
}
/// How a grant is presented (plan 4.2).
@@ -70,17 +54,10 @@ impl Capability {
pub fn risk(&self) -> Risk {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetAsk
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
Risk::Medium
}
Self::Process(_) | Self::FsRead(_) => Risk::High,
}
}
@@ -96,46 +73,26 @@ impl Capability {
Self::ProjectRead => "Read files in the open project".into(),
Self::EditorRead => "Read the focused editor's file and selection".into(),
Self::EditorWrite => "Change text in the editor".into(),
Self::ProjectWrite => "Create and change files in the open project".into(),
Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(),
Self::VcsRead => "Read version control state".into(),
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
Self::Storage => "Store its own data".into(),
Self::Secrets => "Store secrets in the system keychain".into(),
Self::Events => "Observe editor, filesystem and version control events".into(),
Self::Events => "Observe workbench events (files opened, saved, focus)".into(),
Self::InputEvents => "Observe keystrokes and editor input".into(),
Self::Clipboard => {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetAsk => {
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
.into()
}
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
Self::Ipc(path) => {
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
}
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
}
}
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
pub fn covers(&self, wanted: &Capability) -> bool {
if self == wanted {
return true;
}
match (self, wanted) {
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
host_matches(pattern, host)
}
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
(Self::FsRead(base), Self::FsRead(path)) => {
std::path::Path::new(path).starts_with(std::path::Path::new(base))
}
_ => self == wanted,
@@ -153,84 +110,12 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
}
}
/// Validates a socket path; a grant may use `*` in its last segment only.
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
let pipe = name.starts_with(r"\\.\pipe\");
let unix = name.starts_with('/')
|| name.starts_with("~/")
|| name.starts_with("$TMPDIR/")
|| name.starts_with("$XDG_RUNTIME_DIR/");
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
if !(pipe || unix)
|| name.contains(['\0', '?'])
|| name.len() > 4096
|| parts.last().is_none_or(|p| p.is_empty())
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|| (!pattern && name.contains('*'))
{
return Err(
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
);
}
Ok(())
}
/// Expands the three supported host-local prefixes; no shell is involved.
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
validate_ipc_name(name, true)?;
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
if let Some(rest) = name.strip_prefix(prefix) {
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
validate_ipc_name(&expanded, true)?;
return Ok(expanded);
}
}
Ok(name.into())
}
fn ipc_matches(pattern: &str, name: &str) -> bool {
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
if validate_ipc_name(&name, false).is_err() {
return false;
}
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
else {
return false;
};
if parent != other {
return false;
}
let mut rest = name;
let mut pieces = pattern.split('*');
let first = pieces.next().unwrap_or_default();
let Some(tail) = rest.strip_prefix(first) else { return false };
rest = tail;
let pieces: Vec<_> = pieces.collect();
if pieces.is_empty() {
return rest.is_empty();
}
for (i, piece) in pieces.iter().enumerate() {
if i + 1 == pieces.len() {
return rest.ends_with(piece);
}
let Some(at) = rest.find(piece) else { return false };
rest = &rest[at + piece.len()..];
}
true
}
impl fmt::Display for Capability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ProjectRead => f.write_str("project.read"),
Self::EditorRead => f.write_str("editor.read"),
Self::EditorWrite => f.write_str("editor.write"),
Self::ProjectWrite => f.write_str("project.write"),
Self::IdeCommands => f.write_str("ide.commands"),
Self::DatabaseSources => f.write_str("database.sources"),
Self::VcsRead => f.write_str("vcs.read"),
Self::VcsWrite => f.write_str("vcs.write"),
Self::Storage => f.write_str("storage"),
@@ -239,12 +124,8 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetAsk => f.write_str("net:ask"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
Self::FsRead(p) => write!(f, "fs.read:{p}"),
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
}
}
}
@@ -260,10 +141,7 @@ impl FromStr for Capability {
return Err(format!("capability {s:?} needs a value after ':'"));
}
return match kind {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
"net" => {
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -272,19 +150,7 @@ impl FromStr for Capability {
&& label.len() <= 63
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
if valid {
Ok(if kind == "net" {
Self::Net(arg.to_ascii_lowercase())
} else {
Self::NetHttp(arg.to_ascii_lowercase())
})
} else {
Err(format!("invalid host in {s:?}"))
}
}
"ipc" => {
validate_ipc_name(arg, true)?;
Ok(Self::Ipc(arg.to_owned()))
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
}
"process" => {
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
@@ -292,11 +158,11 @@ impl FromStr for Capability {
}
Ok(Self::Process(arg.to_owned()))
}
"fs.read" | "fs.write" => {
"fs.read" => {
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
}
Ok(if kind == "fs.read" { Self::FsRead(arg.to_owned()) } else { Self::FsWrite(arg.to_owned()) })
Ok(Self::FsRead(arg.to_owned()))
}
_ => Err(format!("unknown capability {s:?}")),
};
@@ -305,9 +171,6 @@ impl FromStr for Capability {
"project.read" | "project-read" => Self::ProjectRead,
"editor.read" | "editor-read" => Self::EditorRead,
"editor.write" | "editor-write" => Self::EditorWrite,
"project.write" => Self::ProjectWrite,
"ide.commands" => Self::IdeCommands,
"database.sources" => Self::DatabaseSources,
"vcs.read" => Self::VcsRead,
"vcs.write" => Self::VcsWrite,
"storage" => Self::Storage,
@@ -361,7 +224,6 @@ mod tests {
for s in [
"project.read",
"editor.write",
"project.write",
"vcs.write",
"storage",
"secrets",
@@ -374,12 +236,12 @@ mod tests {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// Absolute paths are the platform's.
let (git, etc, out) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows", r"fs.write:C:\out")
let (git, etc) = if cfg!(windows) {
(r"process:C:\Program Files\Git\bin\git.exe", r"fs.read:C:\Windows")
} else {
("process:/usr/bin/git", "fs.read:/etc", "fs.write:/out")
("process:/usr/bin/git", "fs.read:/etc")
};
for s in [git, etc, out] {
for s in [git, etc] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
@@ -387,7 +249,6 @@ mod tests {
assert!("net:a b".parse::<Capability>().is_err());
assert!("process:./evil".parse::<Capability>().is_err());
assert!("fs.read:rel".parse::<Capability>().is_err());
assert!("fs.write:rel".parse::<Capability>().is_err());
assert!("network".parse::<Capability>().is_err());
}
@@ -416,90 +277,3 @@ mod tests {
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
let http: Capability = "net.http:*.example.com".parse().unwrap();
assert_eq!(http.to_string(), "net.http:*.example.com");
assert_eq!(http.risk(), Risk::Medium);
assert!(http.describe().contains("unencrypted"));
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
assert_eq!(socket.risk(), Risk::High);
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
assert!(
r"ipc:\\.\pipe\discord-*"
.parse::<Capability>()
.unwrap()
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
);
for invalid in
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
{
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
}
}
}
#[cfg(test)]
mod command_tests {
use super::*;
#[test]
fn ide_commands_is_explicit_and_high_risk() {
let cap: Capability = "ide.commands".parse().unwrap();
assert_eq!(cap, Capability::IdeCommands);
assert_eq!(cap.to_string(), "ide.commands");
assert_eq!(cap.risk(), Risk::High);
assert!(!cap.auto_migrates());
assert!(!Capability::VcsWrite.covers(&cap));
assert!(!Capability::Process("git".into()).covers(&cap));
}
}
#[cfg(test)]
mod net_ask_tests {
use super::*;
#[test]
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
let cap: Capability = "net:ask".parse().unwrap();
assert_eq!(cap, Capability::NetAsk);
assert_eq!(cap.to_string(), "net:ask");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
// It asks; it does not itself connect anywhere or pre-approve a host.
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
#[test]
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
let cap: Capability = "database.sources".parse().unwrap();
assert_eq!(cap, Capability::DatabaseSources);
assert_eq!(cap.to_string(), "database.sources");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
assert!(!Capability::ProjectRead.covers(&cap));
assert!(!Capability::ProjectWrite.covers(&cap));
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
let text = format!(
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
);
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
}
}
}
+4 -208
View File
@@ -53,21 +53,12 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
/// `process:<program>` → the program confirmed for it.
#[serde(default)]
pub binaries: BTreeMap<String, BinaryGrant>,
/// Project root → process grant → the executable approved for that project.
#[serde(default)]
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
/// Explicit global executable choices, retained for the next re-approval.
#[serde(default)]
pub program_choices: BTreeMap<String, PathBuf>,
/// Migrated from 0.1: capabilities not granted yet are asked for on first
/// use instead of blocking the plugin from loading.
#[serde(default)]
@@ -204,9 +195,6 @@ impl Ledger {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
@@ -231,9 +219,6 @@ impl Ledger {
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
@@ -261,35 +246,13 @@ impl Ledger {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Net(host) = cap {
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
}
if cap == &Capability::NetAsk {
grant.net_hosts.clear();
}
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
binaries.remove(&s);
}
}
grant.granted.remove(&s)
}
}
/// Remembers one exact host without granting a manifest capability.
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
return false;
}
grant.net_hosts.insert(host.to_owned())
}
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
@@ -297,58 +260,6 @@ impl Ledger {
}
}
/// The user's explicit global executable choice, if any.
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
}
/// Remembers or resets an explicit global executable choice after confirmation.
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(path) = path {
grant.program_choices.insert(cap.to_string(), path.into());
} else {
grant.program_choices.remove(&cap.to_string());
}
}
}
/// Updates a project-specific program choice after user confirmation.
pub fn set_project_binary(
&mut self,
subject: &Subject,
cap: &Capability,
root: &Path,
binary: Option<BinaryGrant>,
) {
if let Some(grant) = self.grants.get_mut(subject) {
if let Some(binary) = binary {
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
binaries.remove(&cap.to_string());
}
}
}
/// The project override when present, otherwise the global approved program.
pub fn check_binary_in_project(
&self,
subject: &Subject,
cap: &Capability,
root: Option<&Path>,
) -> Result<PathBuf, String> {
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
if !grant.has(cap) {
return Err(format!("{cap} was not granted"));
}
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
{
return match file_hash(&bin.path) {
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
};
}
self.check_binary(subject, cap)
}
/// Checks a `process:` grant at call time: the program confirmed for it,
/// unchanged since. Errors name what to do.
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
@@ -395,38 +306,15 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
}
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
if !path.is_absolute() || !is_executable(path) {
return Err("choose an absolute executable file".into());
}
let path = canonical_dir(path);
let hash = file_hash(&path).map_err(|e| e.to_string())?;
Ok(BinaryGrant { path, hash })
}
/// Resolves a `process:` program to an absolute path once, at grant time:
/// absolute names are taken as is, others are looked up in `path_var`.
/// The directory is canonicalized but the file name kept as found: `cargo`,
/// `rustc` and friends are often links to a multicall shim (rustup, asdf,
/// mise) that picks the tool by the name it was invoked under — resolving the
/// link itself would start the shim with the tool's arguments. The grant
/// still pins the target's content: a hash read through a link reads the file
/// it points to, so relinking or replacing either still fails the
/// approved-file check.
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
let candidate = Path::new(name);
if candidate.is_absolute() {
return if candidate.is_file() { Ok(candidate.to_path_buf()) } else { Err(format!("{name}: no such program")) };
}
let exts: Vec<String> = if cfg!(windows) {
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into());
let pathext: Vec<String> = pathext.split(';').filter(|e| !e.is_empty()).map(str::to_owned).collect();
// A name given with its extension (`cargo.exe`) is looked up as written.
let has_ext = candidate.extension().is_some_and(|e| {
pathext.iter().any(|x| x.trim_start_matches('.').eq_ignore_ascii_case(&e.to_string_lossy()))
});
if has_ext { vec![String::new()] } else { pathext }
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
} else {
vec![String::new()]
};
@@ -437,23 +325,15 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
for ext in &exts {
let p = dir.join(format!("{name}{ext}"));
if is_executable(&p) {
return Ok(canonical_dir(&p));
return Ok(dunce_canonical(&p));
}
}
}
Err(format!("{name} was not found on PATH"))
}
/// `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.
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())
fn dunce_canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn is_executable(p: &Path) -> bool {
@@ -506,20 +386,6 @@ mod tests {
list.iter().map(|s| s.parse().unwrap()).collect()
}
#[test]
#[cfg(windows)]
fn pinned_program_path_has_no_verbatim_prefix() {
// std::fs::canonicalize returns \\?\-prefixed paths on Windows; a pin
// stored in that form would never match the plain path the program is
// started with. dunce keeps the usual form.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("cargo.exe"), b"").unwrap();
let pinned = resolve_program("cargo.exe", Some(dir.path().as_os_str())).unwrap();
let shown = pinned.to_string_lossy();
assert!(!shown.starts_with(r"\\?\"), "verbatim prefix leaked into the pin: {shown}");
assert!(pinned.is_absolute());
}
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
Request {
subject: Subject::Installed("p".into()),
@@ -560,40 +426,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();
@@ -642,42 +474,6 @@ mod tests {
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
/// A program found as a link stays the link: multicall shims (rustup,
/// asdf, mise) pick the tool by the name they were invoked under, so
/// `Command::new` must see the link. The hash still covers the target —
/// reading through the link reads it — so replacing either still trips
/// the approved-file check.
#[cfg(unix)]
#[test]
fn linked_programs_resolve_to_the_link() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bin_dir = tmp.path().join("bin");
let real_dir = tmp.path().join("real");
std::fs::create_dir(&bin_dir).unwrap();
std::fs::create_dir(&real_dir).unwrap();
let target = real_dir.join("shim");
std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap();
let path_var = std::env::join_paths([bin_dir.clone()]).unwrap();
let resolved = resolve_program("cargo", Some(&path_var)).unwrap();
// Directory canonicalized, file name kept — still the link.
let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo");
assert_eq!(resolved, want);
assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink());
let mut l = Ledger::in_memory();
let r = req("1.0.0", "h", &["process:cargo"], &[]);
let cap: Capability = "process:cargo".parse().unwrap();
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
std::fs::write(&target, "#!/bin/sh\necho evil\n").unwrap();
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
}
#[test]
fn hashes_and_grouping() {
let tmp = tempfile::tempdir().unwrap();
+2 -9
View File
@@ -19,25 +19,18 @@ mod index;
pub mod ledger;
mod manifest;
pub mod package;
mod plugin_point;
mod point;
mod registry;
mod resolve;
mod ui;
mod when;
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use plugin_point::{FieldType, PointDecl};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{
ChangeListener, Contribution, ContributionInfo, DeclaredPoint, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT,
PreparedSet,
};
pub use resolve::{Blocked, Dependency, DependencyState, Node, Plan, dependency_state, dependents, plan};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
pub use when::validate_when;
+4 -261
View File
@@ -26,15 +26,13 @@
//! activated at start-up, as before.
use crate::capability::Capability;
use crate::plugin_point::PointDecl;
use crate::resolve::Dependency;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.5.0";
pub const ENGINE_VERSION: &str = "1.3.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -68,10 +66,6 @@ pub struct Manifest {
pub wasm: Option<PathBuf>,
pub activation: Vec<ActivationEvent>,
pub capabilities: Capabilities,
/// `[[depends]]`: plugins this one needs (API 1.5).
pub depends: Vec<Dependency>,
/// `[[extension-points]]`: what other plugins may contribute to this one (API 1.5).
pub extension_points: Vec<PointDecl>,
/// `contributes.<point>` → its declarations, validated by the extension points.
pub contributes: toml::Table,
}
@@ -150,21 +144,7 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &[
"run-exited",
"terminal-exited",
"content-tab-action",
"file-closed",
"settings-changed",
"input-mode-changed",
"extensions-changed",
"file-opened",
"file-saved",
"files-changed",
"editor-focused",
"action-performed",
"vcs-changed",
];
pub const EVENT_KINDS: &[&str] = &["file-opened", "file-saved", "editor-focused", "action-performed", "vcs-changed"];
impl std::fmt::Display for ActivationEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -217,10 +197,6 @@ struct Raw {
#[serde(default)]
permissions: Option<Vec<String>>,
#[serde(default)]
depends: Vec<Dependency>,
#[serde(default)]
extension_points: Vec<PointDecl>,
#[serde(default)]
contributes: toml::Table,
}
@@ -292,8 +268,6 @@ impl Manifest {
wasm: raw.wasm,
activation,
capabilities,
depends: raw.depends,
extension_points: raw.extension_points,
contributes: raw.contributes,
};
m.validate()?;
@@ -341,20 +315,6 @@ impl Manifest {
self.needs_api("`icon`", 2)?;
check_relative(icon, "icon")?;
}
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
if matches!(
cap,
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
) {
self.needs_api(&format!("capability `{cap}`"), 4)?;
}
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
return Err("`icon-dark` needs `icon`".into());
@@ -366,16 +326,6 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "extensions-changed") {
self.needs_api("`on-event:extensions-changed`", 5)?;
}
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
self.needs_api("`on-event:files-changed`", 3)?;
}
if !seen.insert(event) {
return Err(format!("duplicate activation event {event}"));
}
@@ -383,7 +333,6 @@ impl Manifest {
for c in &self.declared_categories {
validate_item_id(c, "category")?;
}
self.validate_relations()?;
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
@@ -393,42 +342,6 @@ impl Manifest {
Ok(())
}
/// `[[depends]]` and `[[extension-points]]`.
fn validate_relations(&self) -> Result<(), String> {
if !self.depends.is_empty() {
self.needs_api("`[[depends]]`", 5)?;
}
if !self.extension_points.is_empty() {
self.needs_api("`[[extension-points]]`", 5)?;
}
let mut required = BTreeSet::new();
for dep in &self.depends {
validate_plugin_id(&dep.id)?;
if dep.id == self.id {
return Err(format!("`depends`: {} cannot depend on itself", self.id));
}
if let Some(req) = &dep.version
&& semver::VersionReq::parse(req).is_err()
{
return Err(format!(
"`depends`: {:?} is not a version requirement like \"^1.2\" (plugin {})",
req, dep.id
));
}
if !required.insert(&dep.id) {
return Err(format!("`depends`: {} is listed twice", dep.id));
}
}
let mut declared = BTreeSet::new();
for point in &self.extension_points {
point.validate(&self.id)?;
if !declared.insert(&point.id) {
return Err(format!("extension point {} is declared twice", point.id));
}
}
Ok(())
}
/// The declarations of one extension point: `[[contributes.x]]` gives
/// several, `[contributes.x]` one.
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
@@ -525,12 +438,11 @@ pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
}
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
/// Points declared by plugins are `<plugin id>.<name>`, which may be longer and contain `_`.
pub fn validate_point_id(id: &str) -> Result<(), String> {
let ok = !id.is_empty()
&& id.len() <= 128
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_lowercase())
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'));
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
}
@@ -578,10 +490,6 @@ mod tests {
fn event_activation() {
assert_eq!(ActivationEvent::parse("on-event:file-saved"), Ok(ActivationEvent::Event("file-saved".into())));
assert_eq!(ActivationEvent::Event("vcs-changed".into()).to_string(), "on-event:vcs-changed");
assert_eq!(
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -675,29 +583,6 @@ mod tests {
const BASE: &str = "id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n";
#[test]
fn files_changed_activation_needs_api_1_3() {
let activation = "wasm = \"plugin.wasm\"\nactivation = [\"on-event:files-changed\"]\n";
for engine in ["^1.0", "^1.2", ">=1.1, <2"] {
let base = BASE.replace("^1.2", engine);
assert!(err(&format!("{base}{activation}")).contains("new in plugin API 1.3"), "{engine}");
}
for engine in ["^1.3", ">=1.3, <2"] {
Manifest::parse(&format!("{}{activation}", BASE.replace("^1.2", engine))).unwrap();
}
Manifest::parse(&format!("{BASE}{}", activation.replace("files-changed", "file-saved"))).unwrap();
}
#[test]
fn write_capabilities_need_api_1_3() {
for cap in ["project.write", "fs.write:~/tool"] {
let caps = format!("[capabilities]\noptional = [\"{cap}\"]\n");
assert!(err(&format!("{BASE}{caps}")).contains("new in plugin API 1.3"), "{cap}");
let m = Manifest::parse(&format!("{}{caps}", BASE.replace("^1.2", "^1.3"))).unwrap();
assert_eq!(m.capabilities.optional[0].to_string(), cap);
}
}
#[test]
fn parses_icons() {
let m = Manifest::parse(&format!("{BASE}icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n")).unwrap();
@@ -734,145 +619,3 @@ mod tests {
assert!(!engine_compatible("0.10", true));
}
}
#[cfg(test)]
mod api14_tests {
use super::*;
#[test]
fn new_capabilities_and_activations_require_api_1_4() {
for declaration in [
"[capabilities]\nrequired = [\"net.http:example.com\"]",
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
"activation = [\"on-event:file-closed\"]",
"activation = [\"on-event:settings-changed\"]",
"activation = [\"on-event:input-mode-changed\"]",
"activation = [\"on-event:run-exited\"]",
"activation = [\"on-event:terminal-exited\"]",
"activation = [\"on-event:content-tab-action\"]",
] {
for engine in ["^1.0", "^1.3", "^1.4"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.4" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
}
}
}
}
}
#[cfg(test)]
mod relations_tests {
use super::*;
const HEAD: &str = "id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n";
fn parse(rest: &str) -> Result<Manifest, String> {
Manifest::parse(&format!("{HEAD}{rest}"))
}
#[test]
fn parses_depends_and_extension_points() {
let m = parse(
r#"
[[depends]]
id = "acme.base"
version = "^0.2"
[[depends]]
id = "acme.extra"
optional = true
[[extension-points]]
id = "acme.app.hooks"
required = ["name"]
fields = { name = "string" }
[[contributes."acme.base.things"]]
name = "x"
"#,
)
.unwrap();
assert_eq!(m.depends.len(), 2);
assert_eq!(m.depends[0].version.as_deref(), Some("^0.2"));
assert!(m.depends[1].optional);
assert_eq!(m.extension_points[0].id, "acme.app.hooks");
assert_eq!(m.declarations("acme.base.things").len(), 1);
}
#[test]
fn the_new_keys_need_api_1_5() {
let older =
"id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nwasm = \"p.wasm\"\nengines.openidea = \"^1.4\"\n";
let err = Manifest::parse(&format!("{older}[[depends]]\nid = \"acme.base\"\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let err = Manifest::parse(&format!("{older}[[extension-points]]\nid = \"acme.app.x\"\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let err = Manifest::parse(&format!("{older}activation = [\"on-event:extensions-changed\"]\n")).unwrap_err();
assert!(err.contains("new in plugin API 1.5"), "{err}");
let current = older.replace("^1.4", "^1.5");
assert!(Manifest::parse(&format!("{current}activation = [\"on-event:extensions-changed\"]\n")).is_ok());
}
#[test]
fn refuses_bad_dependencies() {
assert!(parse("[[depends]]\nid = \"acme.app\"\n").unwrap_err().contains("itself"));
assert!(parse("[[depends]]\nid = \"Bad Id\"\n").unwrap_err().contains("invalid plugin id"));
assert!(
parse("[[depends]]\nid = \"acme.base\"\nversion = \"one\"\n").unwrap_err().contains("version requirement")
);
let twice = "[[depends]]\nid = \"acme.base\"\n[[depends]]\nid = \"acme.base\"\n";
assert!(parse(twice).unwrap_err().contains("listed twice"));
assert!(parse("[[depends]]\nid = \"acme.base\"\nunknown = 1\n").is_err());
}
#[test]
fn refuses_points_outside_the_plugins_namespace() {
let err = parse("[[extension-points]]\nid = \"other.thing\"\n").unwrap_err();
assert!(err.contains("acme.app.<name>"), "{err}");
let twice = "[[extension-points]]\nid = \"acme.app.x\"\n[[extension-points]]\nid = \"acme.app.x\"\n";
assert!(parse(twice).unwrap_err().contains("declared twice"));
}
#[test]
fn plugin_ids_with_underscores_work_as_contribution_keys() {
assert!(validate_point_id("my_tools.base.things").is_ok());
assert!(validate_point_id("Bad").is_err());
}
}
#[cfg(test)]
mod commands_api_tests {
use super::*;
#[test]
fn ide_commands_requires_api_1_4() {
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
}
}
#[cfg(test)]
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.5" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
}
}
}
}
}
-192
View File
@@ -1,192 +0,0 @@
//! Extension points a plugin declares for other plugins to contribute to
//! (`[[extension-points]]`), and the check of a contribution against them.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
const MAX_FIELDS: usize = 32;
const MAX_ID: usize = 128;
/// Longest string a contribution may carry in one field.
const MAX_TEXT: usize = 64 * 1024;
const MAX_LIST: usize = 1000;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum FieldType {
String,
Boolean,
Integer,
Number,
StringList,
}
/// `[[extension-points]]`: a kind of contribution the declaring plugin's code reads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(deny_unknown_fields)]
pub struct PointDecl {
/// `<plugin id>.<name>`.
pub id: String,
#[serde(default)]
pub description: String,
/// Fields a contribution must have.
#[serde(default)]
pub required: Vec<String>,
/// Every field a contribution may have; a contribution with another key is refused.
#[serde(default)]
pub fields: BTreeMap<String, FieldType>,
}
impl PointDecl {
pub fn validate(&self, plugin_id: &str) -> Result<(), String> {
let prefix = format!("{plugin_id}.");
let name = self.id.strip_prefix(&prefix).unwrap_or("");
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if !name_ok || self.id.len() > MAX_ID {
return Err(format!(
"invalid extension point {:?}: use \"{plugin_id}.<name>\" with a-z, 0-9, '.', '-', '_' in the name",
self.id
));
}
if self.description.len() > 500 {
return Err(format!("extension point {}: `description` is longer than 500 characters", self.id));
}
if self.fields.len() > MAX_FIELDS {
return Err(format!("extension point {}: at most {MAX_FIELDS} fields", self.id));
}
for field in self.fields.keys() {
let ok = field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& field.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
&& field.len() <= 64;
if !ok {
return Err(format!("extension point {}: invalid field name {field:?}", self.id));
}
}
for field in &self.required {
if !self.fields.contains_key(field) {
return Err(format!("extension point {}: required field {field:?} is not in `fields`", self.id));
}
}
Ok(())
}
/// Checks one contribution and returns it as JSON for the declaring plugin's code.
pub fn check(&self, value: &toml::Value) -> Result<serde_json::Value, String> {
let toml::Value::Table(table) = value else {
return Err("a contribution is a table".into());
};
for key in table.keys() {
if !self.fields.contains_key(key) {
let known: Vec<&str> = self.fields.keys().map(String::as_str).collect();
return Err(format!("unknown field {key:?} (this extension point has: {})", known.join(", ")));
}
}
for key in &self.required {
if !table.contains_key(key) {
return Err(format!("missing required field {key:?}"));
}
}
let mut out = serde_json::Map::new();
for (key, value) in table {
out.insert(key.clone(), convert(key, self.fields[key], value)?);
}
Ok(serde_json::Value::Object(out))
}
}
fn convert(key: &str, ty: FieldType, value: &toml::Value) -> Result<serde_json::Value, String> {
use serde_json::Value as J;
let wrong = |expected: &str| format!("field {key:?} must be {expected}");
match (ty, value) {
(FieldType::String, toml::Value::String(s)) => text(key, s).map(J::from),
(FieldType::Boolean, toml::Value::Boolean(b)) => Ok(J::from(*b)),
(FieldType::Integer, toml::Value::Integer(i)) => Ok(J::from(*i)),
(FieldType::Number, toml::Value::Integer(i)) => Ok(J::from(*i)),
(FieldType::Number, toml::Value::Float(f)) if f.is_finite() => Ok(J::from(*f)),
(FieldType::StringList, toml::Value::Array(items)) => {
if items.len() > MAX_LIST {
return Err(format!("field {key:?} has more than {MAX_LIST} items"));
}
items
.iter()
.map(|item| match item {
toml::Value::String(s) => text(key, s).map(J::from),
_ => Err(wrong("a list of strings")),
})
.collect::<Result<Vec<_>, _>>()
.map(J::Array)
}
(FieldType::String, _) => Err(wrong("a string")),
(FieldType::Boolean, _) => Err(wrong("true or false")),
(FieldType::Integer, _) => Err(wrong("an integer")),
(FieldType::Number, _) => Err(wrong("a number")),
(FieldType::StringList, _) => Err(wrong("a list of strings")),
}
}
fn text(key: &str, s: &str) -> Result<String, String> {
if s.len() > MAX_TEXT {
return Err(format!("field {key:?} is longer than {} KiB", MAX_TEXT >> 10));
}
Ok(s.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn decl(src: &str) -> PointDecl {
toml::from_str(src).unwrap()
}
const COMPLETIONS: &str = r#"
id = "acme.base.completions"
description = "Extra completions"
required = ["label"]
fields = { label = "string", detail = "string", weight = "integer", scopes = "string-list" }
"#;
#[test]
fn the_id_is_namespaced_by_the_declaring_plugin() {
assert!(decl(COMPLETIONS).validate("acme.base").is_ok());
let err = decl(COMPLETIONS).validate("acme.other").unwrap_err();
assert!(err.contains("acme.other.<name>"), "{err}");
assert!(decl("id = \"acme.base.\"").validate("acme.base").is_err());
assert!(decl("id = \"acme.base.Bad Name\"").validate("acme.base").is_err());
}
#[test]
fn required_fields_must_be_declared() {
let err = decl("id = \"p.x\"\nrequired = [\"a\"]").validate("p").unwrap_err();
assert!(err.contains("required field \"a\""), "{err}");
}
#[test]
fn checks_a_contribution_and_converts_it_to_json() {
let point = decl(COMPLETIONS);
let contribution: toml::Value =
toml::from_str("label = \"ew.x\"\nweight = 3\nscopes = [\"xml\", \"java\"]").unwrap();
let json = point.check(&contribution).unwrap();
assert_eq!(json, serde_json::json!({"label": "ew.x", "weight": 3, "scopes": ["xml", "java"]}));
}
#[test]
fn refuses_what_the_point_does_not_declare() {
let point = decl(COMPLETIONS);
let check = |src: &str| point.check(&toml::from_str::<toml::Value>(src).unwrap()).unwrap_err();
assert!(check("detail = \"x\"").contains("missing required field \"label\""));
let err = check("label = \"a\"\nextra = 1");
assert!(err.contains("unknown field \"extra\"") && err.contains("label"), "{err}");
assert!(check("label = 1").contains("must be a string"));
assert!(check("label = \"a\"\nweight = \"3\"").contains("must be an integer"));
assert!(check("label = \"a\"\nscopes = [1]").contains("list of strings"));
}
#[test]
fn numbers_take_integers_and_floats() {
let point = decl("id = \"p.x\"\nfields = { ratio = \"number\" }");
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 2").unwrap()).is_ok());
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 0.5").unwrap()).is_ok());
}
}
+5 -299
View File
@@ -1,7 +1,6 @@
//! Extension points plus the contributions committed to them, accounted per plugin.
use crate::capability::Capability;
use crate::plugin_point::PointDecl;
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
use parking_lot::{Mutex, RwLock};
use serde::{Deserialize, Serialize};
@@ -10,11 +9,6 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
/// The point behind a package's `locales/<tag>.json` catalogs: it has no
/// `[[contributes.locales]]` table, the directory declares it.
pub const LOCALES_POINT: &str = "locales";
pub const LOCALES_DIR: &str = "locales";
/// A contribution as the plugin details page lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -24,7 +18,7 @@ pub struct ContributionInfo {
}
struct Entry {
point: String,
point: &'static str,
key: Option<String>,
label: String,
disposable: Disposable,
@@ -39,7 +33,7 @@ pub struct PreparedSet {
pub files: Vec<PathBuf>,
/// Declarations for extension points this host does not know (skipped).
pub warnings: Vec<String>,
items: Vec<(String, Prepared)>,
items: Vec<(&'static str, Prepared)>,
}
impl PreparedSet {
@@ -62,41 +56,11 @@ impl std::fmt::Debug for PreparedSet {
}
}
/// What one plugin contributed to an extension point another plugin declared.
#[derive(Clone, Debug, PartialEq)]
pub struct Contribution {
/// The contributing plugin.
pub plugin: String,
/// The declaration as JSON, checked against the point's fields.
pub data: serde_json::Value,
}
/// An extension point a plugin declared, with who contributes to it.
#[derive(Clone, Debug, PartialEq)]
pub struct DeclaredPoint {
pub owner: String,
pub decl: PointDecl,
/// Plugin ids, in commit order, once each.
pub contributors: Vec<String>,
}
#[derive(Default)]
struct Dynamic {
points: BTreeMap<String, (String, PointDecl)>,
contributions: BTreeMap<String, Vec<Contribution>>,
}
/// Called with (point id, owner plugin) after contributions to a declared point changed.
pub type ChangeListener = Arc<dyn Fn(&str, &str) + Send + Sync>;
#[derive(Default)]
pub struct ExtensionRegistry {
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
/// plugin id → its contributions in commit order.
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
/// Points declared by plugins and what was contributed to them.
dynamic: Arc<Mutex<Dynamic>>,
listener: Arc<RwLock<Option<ChangeListener>>>,
}
impl ExtensionRegistry {
@@ -128,111 +92,6 @@ impl ExtensionRegistry {
self.points.read().get(id).cloned()
}
/// Registers the function told when contributions to a plugin-declared point change.
pub fn set_change_listener(&self, listener: ChangeListener) {
*self.listener.write() = Some(listener);
}
/// The plugin that declared `point`, while it is committed.
pub fn point_owner(&self, point: &str) -> Option<String> {
self.dynamic.lock().points.get(point).map(|(owner, _)| owner.clone())
}
/// What plugins contributed to `point`, in commit order. Empty unless a plugin declares it.
pub fn contributions_to(&self, point: &str) -> Vec<Contribution> {
let dynamic = self.dynamic.lock();
if !dynamic.points.contains_key(point) {
return Vec::new();
}
dynamic.contributions.get(point).cloned().unwrap_or_default()
}
/// The points `owner` declared, with their contributors.
pub fn declared_points(&self, owner: &str) -> Vec<DeclaredPoint> {
let dynamic = self.dynamic.lock();
dynamic
.points
.iter()
.filter(|(_, (o, _))| o == owner)
.map(|(id, (owner, decl))| {
let mut contributors: Vec<String> = Vec::new();
for c in dynamic.contributions.get(id).into_iter().flatten() {
if !contributors.contains(&c.plugin) {
contributors.push(c.plugin.clone());
}
}
DeclaredPoint { owner: owner.clone(), decl: decl.clone(), contributors }
})
.collect()
}
/// Makes `pkg`'s `[[extension-points]]` known: they take effect with the set's commit.
fn prepare_declarations(&self, pkg: &Package) -> Vec<(String, Prepared)> {
pkg.manifest
.extension_points
.iter()
.map(|decl| {
let (dynamic, owner, decl) = (self.dynamic.clone(), pkg.id().to_owned(), decl.clone());
let id = decl.id.clone();
let prepared = Prepared::new(format!("Extension point {id}"), move || {
let id = decl.id.clone();
dynamic.lock().points.insert(id.clone(), (owner.clone(), decl));
Ok(Disposable::new(move || {
dynamic.lock().points.remove(&id);
}))
})
.key(format!("extension-point:{id}"));
(id, prepared)
})
.collect()
}
/// Validates `pkg`'s contributions to a point some plugin declares, or `None`
/// when no plugin declares `point` (the caller then skips it as unknown).
fn prepare_contributions(&self, pkg: &Package, point: &str) -> Option<Result<Vec<Prepared>, String>> {
let declared = self.dynamic.lock().points.get(point).cloned();
let own =
pkg.manifest.extension_points.iter().find(|d| d.id == point).map(|d| (pkg.id().to_owned(), d.clone()));
let (owner, decl) = declared.or(own)?;
if owner != pkg.id() && !pkg.manifest.depends.iter().any(|d| d.id == owner) {
return Some(Err(format!("{point} is declared by {owner}: add it to [[depends]]")));
}
let listener = self.listener.clone();
let result = pkg
.manifest
.declarations(point)
.into_iter()
.enumerate()
.map(|(i, value)| {
let data = decl.check(&value).map_err(|e| format!("contributions[{i}]: {e}"))?;
let label = decl
.required
.first()
.and_then(|field| data.get(field))
.and_then(|v| v.as_str())
.map_or_else(|| point.to_owned(), str::to_owned);
let contribution = Contribution { plugin: pkg.id().to_owned(), data };
let (dynamic, listener, point) = (self.dynamic.clone(), listener.clone(), point.to_owned());
Ok(Prepared::new(label, move || {
dynamic.lock().contributions.entry(point.clone()).or_default().push(contribution.clone());
notify(&dynamic, &listener, &point);
Ok(Disposable::new(move || {
{
let mut guard = dynamic.lock();
if let Some(list) = guard.contributions.get_mut(&point)
&& let Some(pos) = list.iter().position(|c| *c == contribution)
{
list.remove(pos);
}
}
notify(&dynamic, &listener, &point);
}))
}))
})
.collect();
Some(result)
}
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
/// whole on the first invalid declaration; unknown points are skipped with
/// a warning (forward compatibility).
@@ -244,24 +103,9 @@ impl ExtensionRegistry {
warnings: Vec::new(),
items: Vec::new(),
};
// Declared points first: contributions to them (the plugin's own too) commit after.
set.items.extend(self.prepare_declarations(pkg));
for point_id in pkg.manifest.contributes.keys() {
let Some(point) = self.point(point_id) else {
if let Some(result) = self.prepare_contributions(pkg, point_id) {
let prepared = result.map_err(|e| format!("contributes.{point_id}: {e}"))?;
set.items.extend(prepared.into_iter().map(|p| (point_id.clone(), p)));
continue;
}
// Point ids are `<owner id>.<name>`, so the dependency that owns it can be named.
let owner =
pkg.manifest.depends.iter().find(|d| point_id.strip_prefix(&format!("{}.", d.id)).is_some());
let msg = match owner {
Some(owner) => {
format!("contributes.{point_id}: {} is not running, so this is skipped until it is", owner.id)
}
None => format!("contributes.{point_id}: not supported by this version of the IDE; skipped"),
};
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
tracing::warn!(plugin = %pkg.id(), "{msg}");
set.warnings.push(msg);
continue;
@@ -280,23 +124,9 @@ impl ExtensionRegistry {
set.files.push(f.clone());
}
}
set.items.push((point.id().to_owned(), prepared));
set.items.push((point.id(), prepared));
}
}
if let Some(point) = self.point(LOCALES_POINT)
&& !pkg.manifest.contributes.contains_key(LOCALES_POINT)
&& pkg.dir.join(LOCALES_DIR).is_dir()
{
let prepared = point
.prepare(pkg, toml::Value::Table(Default::default()))
.map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?;
for f in &prepared.files {
if !set.files.contains(f) {
set.files.push(f.clone());
}
}
set.items.push((point.id().to_owned(), prepared));
}
Ok(set)
}
@@ -352,7 +182,7 @@ impl ExtensionRegistry {
.lock()
.get(plugin)
.map(|list| {
list.iter().map(|e| ContributionInfo { point: e.point.clone(), label: e.label.clone() }).collect()
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
})
.unwrap_or_default()
}
@@ -368,14 +198,6 @@ impl ExtensionRegistry {
}
}
fn notify(dynamic: &Mutex<Dynamic>, listener: &RwLock<Option<ChangeListener>>, point: &str) {
let owner = dynamic.lock().points.get(point).map(|(owner, _)| owner.clone());
let callback = listener.read().clone();
if let (Some(owner), Some(callback)) = (owner, callback) {
callback(point, &owner);
}
}
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
}
@@ -469,122 +291,6 @@ mod tests {
assert!(reg.contributions("p").is_empty());
}
fn pkg_of(id: &str, body: &str) -> Package {
let src = format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n{body}");
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
}
const BASE: &str = r#"
[[extension-points]]
id = "acme.base.things"
required = ["name"]
fields = { name = "string", weight = "integer" }
"#;
const PLUGIN: &str =
"[[depends]]\nid = \"acme.base\"\n\n[[contributes.\"acme.base.things\"]]\nname = \"a\"\nweight = 2\n";
fn commit(reg: &ExtensionRegistry, pkg: &Package) {
reg.commit(reg.prepare(pkg).unwrap()).unwrap();
}
#[test]
fn a_plugin_declares_a_point_and_a_dependent_contributes_to_it() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
assert_eq!(reg.point_owner("acme.base.things").as_deref(), Some("acme.base"));
assert!(reg.contributions_to("acme.base.things").is_empty());
commit(&reg, &pkg_of("acme.more", PLUGIN));
let got = reg.contributions_to("acme.base.things");
assert_eq!(got.len(), 1);
assert_eq!(got[0].plugin, "acme.more");
assert_eq!(got[0].data, serde_json::json!({"name": "a", "weight": 2}));
assert_eq!(
reg.contributions("acme.more")[0],
ContributionInfo { point: "acme.base.things".into(), label: "a".into() }
);
let declared = reg.declared_points("acme.base");
assert_eq!(declared.len(), 1);
assert_eq!(declared[0].contributors, ["acme.more"]);
assert!(reg.dispose_plugin("acme.more"));
assert!(reg.contributions_to("acme.base.things").is_empty());
assert!(reg.dispose_plugin("acme.base"));
assert_eq!(reg.point_owner("acme.base.things"), None);
}
#[test]
fn contributions_disappear_from_view_while_the_owner_is_gone() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
commit(&reg, &pkg_of("acme.more", PLUGIN));
reg.dispose_plugin("acme.base");
assert!(reg.contributions_to("acme.base.things").is_empty(), "nobody may read a point that is not declared");
commit(&reg, &pkg_of("acme.base", BASE));
assert_eq!(reg.contributions_to("acme.base.things").len(), 1, "back with the owner");
}
#[test]
fn contributing_needs_a_dependency_on_the_owner() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
let err =
reg.prepare(&pkg_of("acme.rogue", "[[contributes.\"acme.base.things\"]]\nname = \"x\"\n")).unwrap_err();
assert!(err.contains("acme.base") && err.contains("[[depends]]"), "{err}");
}
#[test]
fn a_contribution_is_checked_against_the_declared_fields() {
let reg = ExtensionRegistry::new();
commit(&reg, &pkg_of("acme.base", BASE));
let bad = "[[depends]]\nid = \"acme.base\"\n[[contributes.\"acme.base.things\"]]\nweight = 1\n";
let err = reg.prepare(&pkg_of("acme.more", bad)).unwrap_err();
assert_eq!(err, "contributes.acme.base.things: contributions[0]: missing required field \"name\"");
}
#[test]
fn a_point_nobody_declares_is_skipped_with_a_warning() {
let reg = ExtensionRegistry::new();
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
assert!(set.is_empty());
assert!(set.warnings[0].contains("acme.base.things"), "{:?}", set.warnings);
}
#[test]
fn a_missing_owner_is_named_instead_of_blaming_the_ide_version() {
let reg = ExtensionRegistry::new();
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
let warning = &set.warnings[0];
assert!(warning.contains("acme.base") && warning.contains("not running"), "{warning}");
assert!(!warning.contains("this version of the IDE"), "{warning}");
let unrelated = "[[contributes.\"acme.nobody.things\"]]\nname = \"x\"\n";
let set = reg.prepare(&pkg_of("acme.more", unrelated)).unwrap();
assert!(set.warnings[0].contains("this version of the IDE"), "{:?}", set.warnings);
}
#[test]
fn a_plugin_may_contribute_to_its_own_point() {
let reg = ExtensionRegistry::new();
let own = format!("{BASE}\n[[contributes.\"acme.base.things\"]]\nname = \"builtin\"\n");
commit(&reg, &pkg_of("acme.base", &own));
assert_eq!(reg.contributions_to("acme.base.things")[0].data["name"], "builtin");
}
#[test]
fn the_owner_hears_about_changes_to_its_point() {
let reg = ExtensionRegistry::new();
let heard: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
let sink = heard.clone();
reg.set_change_listener(Arc::new(move |point, owner| sink.lock().push((point.into(), owner.into()))));
commit(&reg, &pkg_of("acme.base", BASE));
assert!(heard.lock().is_empty(), "declaring is not a contribution");
commit(&reg, &pkg_of("acme.more", PLUGIN));
reg.dispose_plugin("acme.more");
let expected = ("acme.base.things".to_owned(), "acme.base".to_owned());
assert_eq!(*heard.lock(), [expected.clone(), expected]);
}
#[test]
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
let (reg, _) = setup();
-433
View File
@@ -1,433 +0,0 @@
//! Which plugins can run, and in which order, given what they depend on.
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
/// `[[depends]]` of a manifest.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
pub id: String,
/// Semver requirement on the dependency's version; none accepts any.
#[serde(default)]
pub version: Option<String>,
/// The plugin runs without it; when present it starts first.
#[serde(default)]
pub optional: bool,
}
impl Dependency {
/// `acme.base ^1.0`.
pub fn describe(&self) -> String {
match &self.version {
Some(v) => format!("{} {v}", self.id),
None => self.id.clone(),
}
}
}
/// A plugin as the resolver sees it.
#[derive(Clone, Copy, Debug)]
pub struct Node<'a> {
pub id: &'a str,
pub version: &'a str,
pub depends: &'a [Dependency],
/// Not switched off by the user.
pub enabled: bool,
}
/// Why an enabled plugin cannot run.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Blocked {
/// A required plugin is not installed.
Missing { requirement: String },
/// A required plugin is switched off.
Disabled { id: String },
/// A required plugin is installed in a version that does not satisfy the requirement.
Version { requirement: String, found: String },
/// A required plugin cannot run itself.
Dependency { id: String },
/// The plugin is part of a dependency cycle (`a → b → a`).
Cycle { ring: Vec<String> },
}
impl fmt::Display for Blocked {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Missing { requirement } => write!(f, "Requires {requirement}, which is not installed"),
Self::Disabled { id } => write!(f, "Requires {id}, which is disabled"),
Self::Version { requirement, found } => {
write!(f, "Requires {requirement}, but version {found} is installed")
}
Self::Dependency { id } => write!(f, "Requires {id}, which cannot run"),
Self::Cycle { ring } => write!(f, "Part of a dependency cycle: {}", ring.join(" → ")),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plan {
/// Plugins that can run, every plugin after the plugins it depends on.
pub order: Vec<String>,
/// Enabled plugins that cannot run. Disabled plugins are in neither list.
pub blocked: BTreeMap<String, Blocked>,
}
/// Decides, for every enabled plugin, whether its required plugins allow it to
/// run and the order to start them in (the counterpart of IntelliJ's
/// `PluginSetBuilder`: dependency graph, topological order, cycle report).
pub fn plan(nodes: &[Node<'_>]) -> Plan {
let by_id: BTreeMap<&str, &Node<'_>> = nodes.iter().map(|n| (n.id, n)).collect();
let enabled: Vec<&Node<'_>> = by_id.values().copied().filter(|n| n.enabled).collect();
let mut blocked: BTreeMap<String, Blocked> = BTreeMap::new();
for node in &enabled {
for dep in node.depends.iter().filter(|d| !d.optional) {
if let Some(reason) = direct_reason(dep, by_id.get(dep.id.as_str()).copied()) {
blocked.entry(node.id.to_owned()).or_insert(reason);
}
}
}
// Cycles among plugins whose requirements are otherwise present.
let candidates: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
let graph: Graph<'_> = candidates
.iter()
.map(|id| {
let deps = by_id[id].depends.iter().filter(|d| !d.optional).map(|d| d.id.as_str());
(*id, deps.filter(|d| candidates.contains(d)).collect())
})
.collect();
for component in components(&graph) {
let cyclic = component.len() > 1 || graph[component[0]].contains(&component[0]);
if cyclic {
let ring = ring_of(&component, &graph);
for id in component {
blocked.insert(id.to_owned(), Blocked::Cycle { ring: ring.clone() });
}
}
}
// A plugin that needs a blocked plugin is blocked too.
loop {
let mut changed = false;
for node in &enabled {
if blocked.contains_key(node.id) {
continue;
}
if let Some(dep) = node.depends.iter().find(|d| !d.optional && blocked.contains_key(&d.id)) {
blocked.insert(node.id.to_owned(), Blocked::Dependency { id: dep.id.clone() });
changed = true;
}
}
if !changed {
break;
}
}
let runnable: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
Plan { order: topological(&runnable, &by_id), blocked }
}
/// Plugins that need `id`, directly or through other plugins, whether or not
/// they are enabled. Optional dependencies do not count.
pub fn dependents(nodes: &[Node<'_>], id: &str) -> Vec<String> {
let mut found: BTreeSet<String> = BTreeSet::new();
let mut frontier = vec![id.to_owned()];
while let Some(current) = frontier.pop() {
for node in nodes {
if node.depends.iter().any(|d| !d.optional && d.id == current) && found.insert(node.id.to_owned()) {
frontier.push(node.id.to_owned());
}
}
}
found.remove(id);
found.into_iter().collect()
}
/// How one dependency stands right now, for the plugin details page.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DependencyState {
Satisfied,
Missing,
Disabled,
/// Installed, wrong version.
Version,
/// Installed and enabled, but cannot run itself.
Blocked,
}
pub fn dependency_state(dep: &Dependency, found: Option<&Node<'_>>, found_blocked: bool) -> DependencyState {
match direct_reason(dep, found) {
Some(Blocked::Missing { .. }) => DependencyState::Missing,
Some(Blocked::Disabled { .. }) => DependencyState::Disabled,
Some(Blocked::Version { .. }) => DependencyState::Version,
_ if found_blocked => DependencyState::Blocked,
_ => DependencyState::Satisfied,
}
}
fn direct_reason(dep: &Dependency, found: Option<&Node<'_>>) -> Option<Blocked> {
let Some(target) = found else {
return Some(Blocked::Missing { requirement: dep.describe() });
};
if !target.enabled {
return Some(Blocked::Disabled { id: dep.id.clone() });
}
let want = dep.version.as_deref()?;
let satisfied = VersionReq::parse(want)
.ok()
.zip(Version::parse(target.version).ok())
.is_some_and(|(req, have)| req.matches(&have));
(!satisfied).then(|| Blocked::Version { requirement: dep.describe(), found: target.version.to_owned() })
}
/// Plugin id → the plugin ids it requires.
type Graph<'a> = BTreeMap<&'a str, Vec<&'a str>>;
/// Strongly connected components (Tarjan) of `graph`.
fn components<'a>(graph: &Graph<'a>) -> Vec<Vec<&'a str>> {
struct State<'a, 'g> {
index: usize,
stack: Vec<&'a str>,
info: BTreeMap<&'a str, (usize, usize, bool)>,
out: Vec<Vec<&'a str>>,
graph: &'g Graph<'a>,
}
fn visit<'a>(s: &mut State<'a, '_>, v: &'a str) {
s.info.insert(v, (s.index, s.index, true));
s.index += 1;
s.stack.push(v);
for &w in &s.graph[v] {
match s.info.get(w).copied() {
None => {
visit(s, w);
let low = s.info[w].1;
let entry = s.info.get_mut(v).expect("visited");
entry.1 = entry.1.min(low);
}
Some((index, _, true)) => {
let entry = s.info.get_mut(v).expect("visited");
entry.1 = entry.1.min(index);
}
Some(_) => {}
}
}
let (index, low, _) = s.info[v];
if index == low {
let mut component = Vec::new();
while let Some(w) = s.stack.pop() {
s.info.get_mut(w).expect("visited").2 = false;
component.push(w);
if w == v {
break;
}
}
component.sort_unstable();
s.out.push(component);
}
}
let mut state = State { index: 0, stack: Vec::new(), info: BTreeMap::new(), out: Vec::new(), graph };
for &id in graph.keys() {
if !state.info.contains_key(id) {
visit(&mut state, id);
}
}
state.out
}
/// One cycle through `component`, from its smallest id back to it.
fn ring_of(component: &[&str], graph: &Graph<'_>) -> Vec<String> {
let start = component[0];
let mut ring = vec![start.to_owned()];
let mut current = start;
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
loop {
let mut next: Vec<&str> = graph[current].iter().copied().filter(|d| component.contains(d)).collect();
next.sort_unstable();
// Prefer closing the ring; otherwise take the smallest unseen member.
let step =
if next.contains(&start) { start } else { *next.iter().find(|d| !seen.contains(**d)).unwrap_or(&start) };
ring.push(step.to_owned());
if step == start {
return ring;
}
seen.insert(step);
current = step;
}
}
/// Kahn's algorithm over the runnable plugins; ties break by id. Optional
/// dependencies order too, but never block: leftovers (an optional edge
/// closing a loop) are appended by id.
fn topological(runnable: &BTreeSet<&str>, by_id: &BTreeMap<&str, &Node<'_>>) -> Vec<String> {
let mut waiting: BTreeMap<&str, BTreeSet<&str>> = runnable
.iter()
.map(|id| {
let deps = by_id[id].depends.iter().map(|d| d.id.as_str()).filter(|d| runnable.contains(d) && d != id);
(*id, deps.collect())
})
.collect();
let mut order: Vec<String> = Vec::with_capacity(runnable.len());
while let Some(next) = waiting.iter().find(|(_, deps)| deps.is_empty()).map(|(id, _)| *id) {
waiting.remove(next);
for deps in waiting.values_mut() {
deps.remove(next);
}
order.push(next.to_owned());
}
order.extend(waiting.keys().map(|id| (*id).to_owned()));
order
}
#[cfg(test)]
mod tests {
use super::*;
fn dep(id: &str) -> Dependency {
Dependency { id: id.into(), version: None, optional: false }
}
fn dep_v(id: &str, version: &str) -> Dependency {
Dependency { id: id.into(), version: Some(version.into()), optional: false }
}
fn optional(id: &str) -> Dependency {
Dependency { id: id.into(), version: None, optional: true }
}
fn node<'a>(id: &'a str, depends: &'a [Dependency]) -> Node<'a> {
Node { id, version: "1.0.0", depends, enabled: true }
}
#[test]
fn dependencies_start_first_whatever_the_ids_sort_like() {
let (a, z) = ([dep("z.base")], []);
let plan = plan(&[node("a.app", &a), node("z.base", &z)]);
assert_eq!(plan.order, ["z.base", "a.app"]);
assert!(plan.blocked.is_empty());
}
#[test]
fn independent_plugins_keep_id_order() {
let plan = plan(&[node("b", &[]), node("a", &[]), node("c", &[])]);
assert_eq!(plan.order, ["a", "b", "c"]);
}
#[test]
fn a_missing_requirement_blocks_with_the_requirement_text() {
let needs = [dep_v("acme.base", "^1.0")];
let plan = plan(&[node("acme.app", &needs)]);
assert!(plan.order.is_empty());
assert_eq!(plan.blocked["acme.app"], Blocked::Missing { requirement: "acme.base ^1.0".into() });
assert_eq!(plan.blocked["acme.app"].to_string(), "Requires acme.base ^1.0, which is not installed");
}
#[test]
fn a_disabled_requirement_blocks_but_the_disabled_plugin_itself_is_not_blocked() {
let needs = [dep("acme.base")];
let mut base = node("acme.base", &[]);
base.enabled = false;
let plan = plan(&[base, node("acme.app", &needs)]);
assert_eq!(plan.blocked["acme.app"], Blocked::Disabled { id: "acme.base".into() });
assert!(!plan.blocked.contains_key("acme.base"));
assert!(plan.order.is_empty());
}
#[test]
fn the_installed_version_must_satisfy_the_requirement() {
let needs = [dep_v("acme.base", "^0.2")];
let mut base = node("acme.base", &[]);
base.version = "0.3.0";
let plan_bad = plan(&[base, node("acme.app", &needs)]);
assert_eq!(
plan_bad.blocked["acme.app"],
Blocked::Version { requirement: "acme.base ^0.2".into(), found: "0.3.0".into() }
);
assert_eq!(plan_bad.blocked["acme.app"].to_string(), "Requires acme.base ^0.2, but version 0.3.0 is installed");
base.version = "0.2.7";
let plan_ok = plan(&[base, node("acme.app", &needs)]);
assert!(plan_ok.blocked.is_empty());
assert_eq!(plan_ok.order, ["acme.base", "acme.app"]);
}
#[test]
fn blocking_propagates_to_everything_downstream() {
let (b, c) = ([dep("a")], [dep("b")]);
let plan = plan(&[node("b", &b), node("c", &c)]);
assert_eq!(plan.blocked["b"], Blocked::Missing { requirement: "a".into() });
assert_eq!(plan.blocked["c"], Blocked::Dependency { id: "b".into() });
assert!(plan.order.is_empty());
}
#[test]
fn an_optional_requirement_never_blocks_but_orders_when_present() {
let app = [optional("acme.extra")];
let alone = plan(&[node("acme.app", &app)]);
assert_eq!(alone.order, ["acme.app"]);
assert!(alone.blocked.is_empty());
let with = plan(&[node("acme.app", &app), node("acme.extra", &[])]);
assert_eq!(with.order, ["acme.extra", "acme.app"]);
let mut off = node("acme.extra", &[]);
off.enabled = false;
let disabled = plan(&[node("acme.app", &app), off]);
assert_eq!(disabled.order, ["acme.app"]);
assert!(disabled.blocked.is_empty());
}
#[test]
fn a_cycle_blocks_its_members_and_names_the_ring() {
let (a, b, d) = ([dep("b")], [dep("a")], [dep("a")]);
let plan = plan(&[node("a", &a), node("b", &b), node("d", &d)]);
let ring = Blocked::Cycle { ring: vec!["a".into(), "b".into(), "a".into()] };
assert_eq!(plan.blocked["a"], ring);
assert_eq!(plan.blocked["b"], ring);
assert_eq!(ring.to_string(), "Part of a dependency cycle: a → b → a");
assert_eq!(plan.blocked["d"], Blocked::Dependency { id: "a".into() });
assert!(plan.order.is_empty());
}
#[test]
fn a_plugin_requiring_itself_is_a_cycle() {
let a = [dep("a")];
let plan = plan(&[node("a", &a)]);
assert_eq!(plan.blocked["a"], Blocked::Cycle { ring: vec!["a".into(), "a".into()] });
}
#[test]
fn an_optional_loop_does_not_block_and_still_yields_every_plugin() {
let (a, b) = ([optional("b")], [optional("a")]);
let plan = plan(&[node("a", &a), node("b", &b)]);
assert!(plan.blocked.is_empty());
assert_eq!(plan.order.len(), 2);
}
#[test]
fn dependents_are_transitive_and_ignore_optional_edges() {
let (b, c, d) = ([dep("a")], [dep("b")], [optional("a")]);
let nodes = [node("a", &[]), node("b", &b), node("c", &c), node("d", &d)];
assert_eq!(dependents(&nodes, "a"), ["b", "c"]);
assert_eq!(dependents(&nodes, "c"), Vec::<String>::new());
}
#[test]
fn dependency_state_tells_the_details_page_what_to_say() {
let want = dep_v("base", "^1.0");
assert_eq!(dependency_state(&want, None, false), DependencyState::Missing);
let mut base = node("base", &[]);
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Satisfied);
assert_eq!(dependency_state(&want, Some(&base), true), DependencyState::Blocked);
base.version = "2.0.0";
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Version);
base.enabled = false;
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Disabled);
}
}
+2 -2
View File
@@ -627,8 +627,8 @@ mod tests {
#[test]
fn finds_remote_by_repo() {
let remotes = vec![
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into(), resolved_url: None },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into(), resolved_url: None },
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
];
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
+1 -6
View File
@@ -12,15 +12,11 @@ path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
regex-lite = "0.1"
ide-workspace.workspace = true
ide-language.workspace = true
blake3.workspace = true
ide-index.workspace = true
ide-syntax.workspace = true
ide-search.workspace = true
@@ -52,8 +48,7 @@ ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
semver = "1"
serde.workspace = true
jsonschema = { version = "0.33", default-features = false }
serde_json = { workspace = true, features = ["raw_value"] }
serde_json.workspace = true
toml.workspace = true
specta.workspace = true
specta-typescript.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"
+1 -20
View File
@@ -15,22 +15,8 @@ fn main() {
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
return;
}
if args.first().is_some_and(|a| a == "--connect") {
// One forwarded connection (see `ide_host::relay`): stdout carries its bytes.
let code = match ide_host::relay::parse_args(&args[1..]).and_then(|(host, port)| {
ide_host::relay::relay(&host, port, std::io::stdin(), std::io::stdout().lock())
.map_err(|e| format!("cannot connect to {host}:{port}: {e}"))
}) {
Ok(()) => 0,
Err(e) => {
eprintln!("{e}");
1
}
};
std::process::exit(code);
}
if !args.iter().any(|a| a == "--stdio") {
eprintln!("usage: openidea-server --stdio | --connect <host> <port> | --version");
eprintln!("usage: openidea-server --stdio | --version");
std::process::exit(2);
}
// stdout carries the protocol: logs go to stderr.
@@ -92,11 +78,6 @@ async fn run(base: PathBuf) -> i32 {
None
}
};
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
{
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
}
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
let mut dispatcher = Dispatcher::standard();
if let Some(plugins) = &plugins {
-124
View File
@@ -1,37 +1,9 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Moves the rebuildable local data (tools, language servers, indexes, caches);
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
/// silent fallback to the OS location.
pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
};
// The working directory depends on how the IDE was launched.
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
@@ -304,102 +276,6 @@ fn relative_wrapper(text: &str, source: &Path) -> String {
mod tests {
use super::*;
#[test]
fn local_dir_follows_the_os_unless_the_variable_is_set() {
let os = Some(PathBuf::from("/os-local"));
for var in [None, Some(OsString::new())] {
assert_eq!(
resolve_local(var, os.clone(), "ai.l0ng.openidea").unwrap(),
Path::new("/os-local/ai.l0ng.openidea")
);
}
assert!(resolve_local(None, None, "id").is_err());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("missing/nested");
let dir = resolve_local(Some(root.clone().into()), os.clone(), "ai.l0ng.openidea").unwrap();
assert_eq!(dir, root.join("ai.l0ng.openidea"));
assert!(dir.is_dir());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
// Debug worktrees have their own identifiers and stay apart under one root.
assert_ne!(resolve_local(Some(root.clone().into()), None, "dev-a").unwrap(), dir);
}
#[test]
fn local_dir_preserves_an_existing_probe_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let probe = dir.join(".openidea-write-check");
fs::write(&probe, "keep").unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(probe).unwrap(), "keep");
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn local_dir_preserves_an_existing_probe_symlink_and_its_target() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
let target = tmp.path().join("target");
fs::write(&target, "keep").unwrap();
let probe = dir.join(".openidea-write-check");
std::os::unix::fs::symlink(&target, &probe).unwrap();
assert_eq!(resolve_local(Some(tmp.path().into()), None, "id").unwrap(), dir);
assert_eq!(fs::read_to_string(target).unwrap(), "keep");
assert!(fs::symlink_metadata(probe).unwrap().file_type().is_symlink());
assert_eq!(fs::read_dir(dir).unwrap().count(), 1);
}
#[test]
fn local_dir_allows_concurrent_probes_for_the_same_identifier() {
let tmp = tempfile::tempdir().unwrap();
let barrier = std::sync::Barrier::new(8);
std::thread::scope(|scope| {
for _ in 0..8 {
let barrier = &barrier;
let root = tmp.path();
scope.spawn(move || {
barrier.wait();
for _ in 0..16 {
assert_eq!(resolve_local(Some(root.into()), None, "id").unwrap(), root.join("id"));
}
});
}
});
assert_eq!(fs::read_dir(tmp.path().join("id")).unwrap().count(), 0);
}
#[test]
fn local_dir_rejects_relative_and_unwritable_paths() {
for relative in ["local", "./local", "../local"] {
let error = resolve_local(Some(relative.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains(LOCAL_DATA_ENV) && error.contains("absolute"), "{error}");
}
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("file");
fs::write(&blocker, "").unwrap();
let error = resolve_local(Some(blocker.into()), Some("/os".into()), "id").unwrap_err();
assert!(error.contains("cannot write"), "{error}");
}
#[cfg(unix)]
#[test]
fn local_dir_rejects_an_existing_read_only_directory() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("id");
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
// Root ignores permission bits.
if fs::write(dir.join("probe"), "").is_err() {
assert!(resolve_local(Some(tmp.path().into()), None, "id").unwrap_err().contains("cannot write"));
}
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
+1 -5
View File
@@ -22,10 +22,6 @@ use std::time::Duration;
type Changes = Vec<(OsString, Option<OsString>)>;
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
/// What Reload Environment changed.
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -76,7 +72,7 @@ impl Environment {
return None;
}
let config = self.load_project(root, sink);
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
let post_start = config.and_then(|c| c.post_start);
let first = self.state.lock().post_started.insert(root.to_path_buf());
post_start.filter(|_| first)
}
Loaded 100 of 2220 files, more files were not shown because too many files have changed in this diff. Show more