mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
359
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae558d70f1 | ||
|
|
fc938ac567 | ||
|
|
c4b87ae09e | ||
|
|
8c381a07c4 | ||
|
|
ba8d224a46 | ||
|
|
37f0897eb7 | ||
|
|
ed08b96382 | ||
|
|
9069c322e8 | ||
|
|
b753d3f536 | ||
|
|
ad382a7824 | ||
|
|
a6987b3eaa | ||
|
|
6b0dc93938 | ||
|
|
a6474539b8 | ||
|
|
401b4a9441 | ||
|
|
086c89df5b | ||
|
|
5325d98c3f | ||
|
|
589cf81bae | ||
|
|
a5d1b16e59 | ||
|
|
b450f4028b | ||
|
|
1f76e71621 | ||
|
|
e6b800971a | ||
|
|
a6cf4442d8 | ||
|
|
83d2f67b4f | ||
|
|
3ec30fff3f | ||
|
|
009bb21f0b | ||
|
|
43bb10048d | ||
|
|
1b4ca3cf1f | ||
|
|
08f2d9756d | ||
|
|
b4bd0ec1e2 | ||
|
|
7634ae81f1 | ||
|
|
7bd96a998b | ||
|
|
4830f42168 | ||
|
|
800949b7b5 | ||
|
|
6026374973 | ||
|
|
0a47a685d9 | ||
|
|
653f2ff276 | ||
|
|
8689271e7c | ||
|
|
7ad02da722 | ||
|
|
1dda103bf0 | ||
|
|
844cc93b90 | ||
|
|
c95a525235 | ||
|
|
4002eaa8e9 | ||
|
|
98268106b8 | ||
|
|
2e9a637158 | ||
|
|
f50a8a301c | ||
|
|
07df51f308 | ||
|
|
38ff63658e | ||
|
|
7dbce43c88 | ||
|
|
f68d8f499a | ||
|
|
7a715758a6 | ||
|
|
e5c250aad8 | ||
|
|
ad64506336 | ||
|
|
f3fe837a66 | ||
|
|
af0ffb809e | ||
|
|
31e477b7fb | ||
|
|
425801be34 | ||
|
|
269f3b22fd | ||
|
|
2c6131999c | ||
|
|
97177486e9 | ||
|
|
57d35ecdb4 | ||
|
|
a275239138 | ||
|
|
f35026f31c | ||
|
|
875e049dcd | ||
|
|
4ca44ee815 | ||
|
|
1186cdce64 | ||
|
|
f665beb17e | ||
|
|
d1acf9f9ac | ||
|
|
c179b1a822 | ||
|
|
846ac97650 | ||
|
|
b70c01d1d6 | ||
|
|
653ebd578a | ||
|
|
6e6baf4284 | ||
|
|
de09ee0678 | ||
|
|
292dc3cade | ||
|
|
d4a5b54720 | ||
|
|
c495c09416 | ||
|
|
324dded5c1 | ||
|
|
36fd117b29 | ||
|
|
9fb38769ad | ||
|
|
087cb4cbaa | ||
|
|
f6a1823121 | ||
|
|
4959b430b1 | ||
|
|
915fec598f | ||
|
|
94576eb879 | ||
|
|
8c915664e9 | ||
|
|
253fcfb7e0 | ||
|
|
24cf1a52d5 | ||
|
|
7071fd9107 | ||
|
|
df27de217e | ||
|
|
bff5d11346 | ||
|
|
9a48426740 | ||
|
|
dadec04d62 | ||
|
|
9bd42381b2 | ||
|
|
47e5b0f59c | ||
|
|
4a217a4473 | ||
|
|
1aec21b43f | ||
|
|
d3d0fd23c6 | ||
|
|
c3b7e34b22 | ||
|
|
91d6c7de57 | ||
|
|
abb7b1d962 | ||
|
|
c57156ef4b | ||
|
|
f55cdcdc31 | ||
|
|
7d267ab50e | ||
|
|
50986088f9 | ||
|
|
7a0f60377c | ||
|
|
945f09347e | ||
|
|
2924824e7b | ||
|
|
26e3ecc3a0 | ||
|
|
456b329d73 | ||
|
|
ecda2223eb | ||
|
|
b0f65769c0 | ||
|
|
9bf28dd451 | ||
|
|
3025b274f5 | ||
|
|
bdf94926ec | ||
|
|
893a549b23 | ||
|
|
3ef247c55f | ||
|
|
10a3ac92cf | ||
|
|
43c85ce8f3 | ||
|
|
ac436160b5 | ||
|
|
a3fe841e37 | ||
|
|
ed4a8b50c1 | ||
|
|
e7952ce3d8 | ||
|
|
a0595f5c12 | ||
|
|
090a1cd6df | ||
|
|
2b86dd2d9f | ||
|
|
566190e94b | ||
|
|
8869f1ce1c | ||
|
|
04fe65328e | ||
|
|
0d80e2c05a | ||
|
|
ee5d912157 | ||
|
|
4557811ef8 | ||
|
|
47f7e07a20 | ||
|
|
90e336761d | ||
|
|
73be53b6d9 | ||
|
|
2760c91bde | ||
|
|
a5fb380960 | ||
|
|
8dedf5e73b | ||
|
|
75c4ca0be8 | ||
|
|
7856fabbfe | ||
|
|
4a90f124c6 | ||
|
|
bb4e2db47f | ||
|
|
fec391b9c1 | ||
|
|
43af99d3ec | ||
|
|
3e88fa1f39 | ||
|
|
6b89984863 | ||
|
|
a083571537 | ||
|
|
d3cb7c14ca | ||
|
|
0dfdbbc323 | ||
|
|
682fee633d | ||
|
|
dce57ca83a | ||
|
|
b674d0f2e8 | ||
|
|
001381459e | ||
|
|
38ac62f1a4 | ||
|
|
29c54c243a | ||
|
|
54502e0a17 | ||
|
|
e129840651 | ||
|
|
77f86616c0 | ||
|
|
89ffde304b | ||
|
|
1248c3231e | ||
|
|
ff9f821621 | ||
|
|
5279443ac5 | ||
|
|
e648c0c46f | ||
|
|
791c957284 | ||
|
|
59071ac426 | ||
|
|
ad27a7064a | ||
|
|
116b1624b3 | ||
|
|
de0f9d6ecd | ||
|
|
6d55eb4aa9 | ||
|
|
a247f1ce67 | ||
|
|
f16ec29f4a | ||
|
|
02d30b1609 | ||
|
|
01e09565b7 | ||
|
|
1f70b134de | ||
|
|
11fd64f399 | ||
|
|
8a884b6362 | ||
|
|
481d44930f | ||
|
|
f5895d0336 | ||
|
|
c3c1d88d10 | ||
|
|
a0ac988963 | ||
|
|
d5c4066748 | ||
|
|
8d784ac164 | ||
|
|
7ffaa0b5da | ||
|
|
ab2b53ff80 | ||
|
|
2ba731f0ec | ||
|
|
742e504719 | ||
|
|
ebe059e00f | ||
|
|
e17e59ce7d | ||
|
|
8021a8ccca | ||
|
|
5a6a6f7e05 | ||
|
|
a7f0f8110b | ||
|
|
9669e58398 | ||
|
|
d939dca303 | ||
|
|
23378e0cc2 | ||
|
|
c83264ae75 | ||
|
|
2fc3535300 | ||
|
|
d96ff8e881 | ||
|
|
3b02e6899d | ||
|
|
948e93e1a2 | ||
|
|
5d19baf06b | ||
|
|
89309e40ba | ||
|
|
9f2e6b570f | ||
|
|
2d20e5a203 | ||
|
|
7c60737b69 | ||
|
|
7e9acdc59b | ||
|
|
2013ddaac2 | ||
|
|
ce898ea0c6 | ||
|
|
c219c81bd8 | ||
|
|
70a7c6dd2c | ||
|
|
fd69664ca7 | ||
|
|
11932a87af | ||
|
|
19a245fa04 | ||
|
|
62b5f11ae3 | ||
|
|
19c2d40528 | ||
|
|
78a3768ec1 | ||
|
|
f4fa2b42f6 | ||
|
|
d0910d3313 | ||
|
|
c0a74244b4 | ||
|
|
8d29704d46 | ||
|
|
c399ad2598 | ||
|
|
c2dd466305 | ||
|
|
c27fea13b3 | ||
|
|
94490e2b37 | ||
|
|
f3afe69ca2 | ||
|
|
9a9266f61b | ||
|
|
040578ba1d | ||
|
|
ad2aeed54e | ||
|
|
1e4eee27c4 | ||
|
|
1789dbb98e | ||
|
|
20c439baaa | ||
|
|
a0dfb1e741 | ||
|
|
4906bcb2a1 | ||
|
|
f35c781f42 | ||
|
|
6f9819fee7 | ||
|
|
834b8d4e3b | ||
|
|
7cdf690b4b | ||
|
|
057877a39e | ||
|
|
a08707dabd | ||
|
|
092a700ddc | ||
|
|
b31edc3482 | ||
|
|
d032addb17 | ||
|
|
8f77d7077c | ||
|
|
bdc108a61b | ||
|
|
a363544625 | ||
|
|
f942ea168d | ||
|
|
59f1a1e6db | ||
|
|
a4a16d0854 | ||
|
|
b440370f2d | ||
|
|
aa33d2eeab | ||
|
|
b26318a32c | ||
|
|
8a2e370178 | ||
|
|
61a05e5a36 | ||
|
|
d9355155f3 | ||
|
|
ecd593eb0c | ||
|
|
0716a48247 | ||
|
|
699c71c1f2 | ||
|
|
014f51ae54 | ||
|
|
30b0549196 | ||
|
|
0ecbc6b35d | ||
|
|
859f63454e | ||
|
|
c1ea7a9ef2 | ||
|
|
3100aa2c00 | ||
|
|
52360c72d2 | ||
|
|
7fd330912a | ||
|
|
eaca68f263 | ||
|
|
4b5ef7069c | ||
|
|
2cd16a723d | ||
|
|
71c0515af6 | ||
|
|
b473876d59 | ||
|
|
d7886bab45 | ||
|
|
dd370654cb | ||
|
|
e3c7f362c4 | ||
|
|
02addfa2c5 | ||
|
|
82fe52f595 | ||
|
|
d94a117d69 | ||
|
|
b6b0ce46c3 | ||
|
|
953e844541 | ||
|
|
48e23c012f | ||
|
|
0bc0396f80 | ||
|
|
d20be9c5cc | ||
|
|
2d3b7ebf54 | ||
|
|
6dec1252db | ||
|
|
a610982655 | ||
|
|
ed145e3046 | ||
|
|
5b46844f88 | ||
|
|
c87d2a6b55 | ||
|
|
4fbe41194f | ||
|
|
16df2ae472 | ||
|
|
cbc77941d4 | ||
|
|
3d7768f47f | ||
|
|
8687f6b1e3 | ||
|
|
85c03d33af | ||
|
|
65ca0390de | ||
|
|
b31938db00 | ||
|
|
60969eccb6 | ||
|
|
0a70b0af01 | ||
|
|
268cf01a86 | ||
|
|
fd4921c58a | ||
|
|
e04e5dd57d | ||
|
|
c5e188d1e3 | ||
|
|
d3b9a39831 | ||
|
|
59567f1af0 | ||
|
|
7f0c78893e | ||
|
|
fc6a27d4ba | ||
|
|
5e73b1634f | ||
|
|
e88e7e6459 | ||
|
|
c86928eee7 | ||
|
|
fa6d7276b8 | ||
|
|
1bc7fd06c1 | ||
|
|
280c2da48f | ||
|
|
2ec2b76d7a | ||
|
|
425a7da003 | ||
|
|
87e9bec4f9 | ||
|
|
f088a4afb0 | ||
|
|
2e6cda1d86 | ||
|
|
42968a39b3 | ||
|
|
4d086c44c9 | ||
|
|
770178966f | ||
|
|
2f2d3a25ae | ||
|
|
893cbede9c | ||
|
|
e5718d41d8 | ||
|
|
53b8cdeb51 | ||
|
|
a3bbbf90a4 | ||
|
|
9988ce4113 | ||
|
|
35c6af2367 | ||
|
|
3eabaf0d39 | ||
|
|
192e613830 | ||
|
|
4c5b6de9ad | ||
|
|
6bf1d25711 | ||
|
|
aeb3abca34 | ||
|
|
b9f3c07f9b | ||
|
|
a1dd16f26d | ||
|
|
94665528b9 | ||
|
|
809c6adcf1 | ||
|
|
2bb8f3b091 | ||
|
|
cd77c9c6fb | ||
|
|
d72f8a1506 | ||
|
|
c5749b0c17 | ||
|
|
4d224f80c9 | ||
|
|
63dd83c129 | ||
|
|
afea58c1c6 | ||
|
|
19615e00b9 | ||
|
|
c347e331ab | ||
|
|
9a6b84e287 | ||
|
|
df6fbb6d21 | ||
|
|
d94499b7ac | ||
|
|
f148c1ac6b | ||
|
|
5ad5dc2a28 | ||
|
|
fe0ee338f4 | ||
|
|
e575bf8c24 | ||
|
|
1ed9275da0 | ||
|
|
3a98d5c5be | ||
|
|
6b9ab3b323 | ||
|
|
23e468252f | ||
|
|
a6c5574194 | ||
|
|
5d63f618ef | ||
|
|
2ba07341c2 | ||
|
|
0dffad814a | ||
|
|
1c5a92f952 | ||
|
|
cfdbd3872a |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
Closes #
|
||||
<!-- If this resolves an issue: Closes #<n> -->
|
||||
|
||||
## What and why
|
||||
|
||||
@@ -9,7 +9,8 @@ Closes #
|
||||
|
||||
<!-- Required. Commands you actually ran and their results; what else this could
|
||||
affect and how you checked it still works.
|
||||
UI changes: before/after screenshots. Multi-step interactions: a recording.
|
||||
Visible UI changes: before/after screenshots (not for removed UI or copy-only
|
||||
changes). Multi-step interactions: a recording.
|
||||
Backend changes: logs or command output. -->
|
||||
|
||||
## Review focus
|
||||
|
||||
+125
-12
@@ -7,6 +7,9 @@ on:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
# A newer push supersedes the run in progress, on main too.
|
||||
@@ -15,20 +18,64 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
jobs:
|
||||
# Which parts a pull request touches, so jobs it can't affect skip and leave the
|
||||
# shared runners to others. Pushes and manual runs always run everything.
|
||||
changes:
|
||||
name: Changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
rust: ${{ steps.classify.outputs.rust }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
steps:
|
||||
- id: classify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
|
||||
echo "rust=true" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
|
||||
--jq '.[] | .filename, (.previous_filename // empty)' > files.txt
|
||||
cat files.txt
|
||||
docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
|
||||
# Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
|
||||
web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
|
||||
embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
|
||||
# A job runs when any changed file falls outside the paths that can't affect it.
|
||||
rust=false
|
||||
if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
|
||||
frontend=false
|
||||
if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
|
||||
echo "rust=$rust frontend=$frontend"
|
||||
echo "rust=$rust" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
|
||||
|
||||
rust:
|
||||
name: Rust (${{ matrix.os }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
@@ -60,9 +107,12 @@ jobs:
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 40
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -71,23 +121,83 @@ jobs:
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
# The plugin SDK, the official plugins and the test fixtures build in their
|
||||
# own workspaces (the root excludes them), so the jobs above don't see them.
|
||||
plugins:
|
||||
name: Plugins (SDK, official, fixtures)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: plugins
|
||||
cache-directories: target-plugins
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Format, lint (host and wasm) and test each crate
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in sdk/rust/openidea-plugin plugins/official/*/ crates/ide-plugin/tests/fixtures/*/; do
|
||||
[ -f "$dir/Cargo.toml" ] || continue
|
||||
echo "::group::$dir"
|
||||
cargo fmt --check --manifest-path "$dir/Cargo.toml"
|
||||
cargo clippy --locked --all-targets --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo clippy --locked --target wasm32-wasip2 --manifest-path "$dir/Cargo.toml" -- -D warnings
|
||||
cargo test --locked --manifest-path "$dir/Cargo.toml"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22 }
|
||||
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
|
||||
- name: Web pages typecheck and test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for dir in plugins/official/*/web-src/; do
|
||||
[ -f "$dir/package.json" ] || continue
|
||||
echo "::group::$dir"
|
||||
pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace
|
||||
pnpm --dir "$dir" exec tsc --noEmit
|
||||
pnpm --dir "$dir" test
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The host's tests run the checked-in components: they must be built from these sources.
|
||||
# Web pages aren't checked in: this builds them and checks the result.
|
||||
- name: Checked-in components are up to date, web pages build
|
||||
run: plugins/build.sh --check
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
- run: cargo deny check
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
@@ -99,20 +209,23 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-24.04
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: &paths
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
|
||||
VTSLS: 0.3.0
|
||||
TYPESCRIPT: 6.0.3
|
||||
BASEDPYRIGHT: 1.40.2
|
||||
RUFF: 0.17.3
|
||||
GOPLS: v0.24.0
|
||||
JDTLS: 1.63.0-202610191322
|
||||
KOTLIN_LSP: 263.7084.0
|
||||
GRADLE: 8.14.3
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Language smoke (${{ matrix.language }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [typescript, python, rust, go, java, kotlin]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "rust-analyzer, rustfmt" }
|
||||
# One build cache for every language; main saves it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
- if: matrix.language == 'python'
|
||||
uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- if: matrix.language == 'python'
|
||||
run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF"
|
||||
|
||||
- if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v5
|
||||
with: { go-version: "1.27", cache: false }
|
||||
- if: matrix.language == 'go'
|
||||
run: go install "golang.org/x/tools/gopls@$GOPLS"
|
||||
|
||||
- if: matrix.language == 'java' || matrix.language == 'kotlin'
|
||||
uses: actions/setup-java@v4
|
||||
with: { distribution: temurin, java-version: "21" }
|
||||
- if: matrix.language == 'java'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/jdtls"
|
||||
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/jdtls"
|
||||
echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH.
|
||||
- if: matrix.language == 'kotlin'
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with: { gradle-version: "${{ env.GRADLE }}" }
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin"
|
||||
curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \
|
||||
| tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1
|
||||
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
|
||||
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- 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'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Plugin index
|
||||
|
||||
# Refreshes the community plugins in the latest release's plugins-index.json
|
||||
# (what released apps read) from the registry, l0ng-ai/openidea-plugins. The
|
||||
# official plugins, their key and revocations stay as the release wrote them.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
# Sent by the registry when its index changes.
|
||||
repository_dispatch:
|
||||
types: [community-plugins]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: plugins-index
|
||||
|
||||
env:
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RUNNER_TEMP"
|
||||
tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)"
|
||||
tool=openidea-plugin-linux-x86_64
|
||||
if ! gh release download "$tag" --repo "$GITHUB_REPOSITORY" -p plugins-index.json -p "$tool.gz"; then
|
||||
echo "::notice::$tag has no plugin index or openidea-plugin tool yet"
|
||||
exit 0
|
||||
fi
|
||||
gunzip "$tool.gz" && chmod +x "$tool"
|
||||
cp plugins-index.json before.json
|
||||
"./$tool" merge plugins-index.json "$COMMUNITY_PLUGINS_INDEX" --keep openidea
|
||||
if cmp -s before.json plugins-index.json; then
|
||||
echo "unchanged"
|
||||
else
|
||||
gh release upload "$tag" plugins-index.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
fi
|
||||
@@ -18,24 +18,15 @@ jobs:
|
||||
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const LABEL = 'needs-info';
|
||||
const MARKER = '<!-- pr-check -->';
|
||||
// Docs, CI and pure refactors may skip the linked issue.
|
||||
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
|
||||
|
||||
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
|
||||
const problems = [];
|
||||
|
||||
const linksIssue =
|
||||
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
|
||||
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
|
||||
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
|
||||
}
|
||||
|
||||
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
|
||||
if (!verified || !verified[1].trim()) {
|
||||
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
|
||||
|
||||
@@ -18,6 +18,7 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
@@ -25,8 +26,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- name: Tag matches app version
|
||||
run: |
|
||||
@@ -35,8 +37,32 @@ jobs:
|
||||
echo "::error::tag $TAG does not match tauri.conf.json version $app"
|
||||
exit 1
|
||||
fi
|
||||
# A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later
|
||||
# versions against (plugins/README.md, "Evolving the plugin API").
|
||||
- name: Plugin API snapshot matches wit/
|
||||
run: |
|
||||
api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; }
|
||||
snapshot=sdk/rust/openidea-plugin/tests/api/released
|
||||
if [ "$(api wit)" != "$(api "$snapshot")" ]; then
|
||||
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@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const tag = process.env.TAG;
|
||||
@@ -57,6 +83,8 @@ jobs:
|
||||
build:
|
||||
needs: create-release
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -81,11 +109,11 @@ jobs:
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v5
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -155,7 +183,7 @@ jobs:
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
@@ -180,13 +208,86 @@ jobs:
|
||||
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
|
||||
# installers: users install them from Settings › Plugins. Without the
|
||||
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
|
||||
# writes) nothing is published and the release goes on. The index also lists
|
||||
# the community plugins of the registry (COMMUNITY_PLUGINS_INDEX; refreshed
|
||||
# between releases by plugins-index.yml), and the release carries the
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
steps:
|
||||
- id: key
|
||||
run: |
|
||||
if [ -z "$SIGNING_KEY" ]; then
|
||||
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { targets: wasm32-wasip2 }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { key: release-plugins }
|
||||
- uses: pnpm/action-setup@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
# Builds the web pages; fails when a checked-in component isn't built from these sources.
|
||||
- name: Build
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
run: |
|
||||
plugins/build.sh --check
|
||||
cargo build --release --locked -p ide-host --bin openidea-plugin
|
||||
- name: Pack, index and upload
|
||||
if: steps.key.outputs.skip != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tool="$PWD/target/release/openidea-plugin"
|
||||
key="$RUNNER_TEMP/openidea.key"
|
||||
out="$RUNNER_TEMP/market"
|
||||
(umask 077 && printf '%s' "$SIGNING_KEY" > "$key")
|
||||
trap 'rm -f "$key"' EXIT
|
||||
mkdir -p "$out"
|
||||
for dir in plugins/official/*/; do
|
||||
id="$(sed -n 's/^id *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
|
||||
done
|
||||
# The published index's revocations, earlier keys and community plugins carry over.
|
||||
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
|
||||
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
|
||||
# Downloads point at this release: older versions kept for older IDEs stay where they were published.
|
||||
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" \
|
||||
--download-base "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG"
|
||||
"$tool" merge "$out/plugins-index.json" "$COMMUNITY_PLUGINS_INDEX" --keep openidea ||
|
||||
echo "::warning::the community plugins were not refreshed"
|
||||
gzip -9 -c "$tool" > "$out/openidea-plugin-linux-x86_64.gz"
|
||||
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@v7
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
@@ -243,10 +344,10 @@ jobs:
|
||||
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
|
||||
|
||||
publish:
|
||||
needs: [create-release, build, server, updater-manifest]
|
||||
needs: [create-release, build, server, plugins, updater-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
- uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
|
||||
@@ -10,6 +10,9 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
# Plugin components are checked in (plugins/build.sh --check keeps them current).
|
||||
!plugins/official/*/dist
|
||||
!crates/ide-plugin/tests/fixtures/*/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -11,10 +11,13 @@
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
"fixtures",
|
||||
"plugins/*/web",
|
||||
"plugins/official/*/web",
|
||||
"crates/**/tests/fixtures"
|
||||
],
|
||||
"overrides": [
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
|
||||
@@ -18,9 +18,12 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
- `src/ui/` — design-system primitives. `src/features/<name>/` — one feature each,
|
||||
exporting `activate()` registered in `src/features/index.ts`.
|
||||
- `languages/<id>.toml` — language definitions (detection, comments, LSP, DAP).
|
||||
- `crates/ide-host` — the `openidea-server` binary for remote development.
|
||||
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
|
||||
`templates/` — plugin starter templates.
|
||||
- `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`, `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.
|
||||
|
||||
@@ -39,15 +42,17 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
|
||||
- User-visible text goes through the text catalog: add every new string to both
|
||||
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
|
||||
Other locales may lack entries; their UI shows the English text until a translator adds it.
|
||||
- Keep comments sparse and purposeful; match surrounding style.
|
||||
- For GUI verification, prefer Tauri MCP; use Computer Use only when Tauri MCP is
|
||||
unavailable or cannot cover the required checks.
|
||||
|
||||
## Issues & PRs
|
||||
|
||||
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one issue per PR with
|
||||
`Closes #<n>`, reproduce first, keep the diff focused, short plain description, and a
|
||||
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
|
||||
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
|
||||
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
|
||||
**How it was verified** section with commands run, results and, when the change alters what users
|
||||
see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
|
||||
Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
@@ -63,10 +68,19 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
this worktree's running MCP bridges and actual ports.
|
||||
- `pnpm build` — typecheck + bundle frontend.
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
benches vs budgets). Budgets: `perf/budgets.json`.
|
||||
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
|
||||
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
@@ -110,8 +124,8 @@ Several worktrees can be developed and run side by side.
|
||||
default MCP session belongs to this worktree.
|
||||
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
|
||||
`target` symlinks at a shared directory.
|
||||
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
|
||||
15–30 GB. Avoid running several heavy builds on one machine at the same time.
|
||||
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
|
||||
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
|
||||
- Stop only processes started for the current worktree/session. Never use broad
|
||||
process-name kills such as `pkill openidea`.
|
||||
- When done: stop that worktree's app and dev session, delete its build output, then
|
||||
|
||||
+10
-6
@@ -16,8 +16,7 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
|
||||
Docs, CI and pure refactors may skip the issue.
|
||||
- One problem per PR. If it resolves an issue, link it with `Closes #123`.
|
||||
- Reproduce the problem first, or write a test that fails before the fix.
|
||||
- Fix the cause, and check whether the same cause breaks anything nearby.
|
||||
- Keep the diff focused. Unrelated refactors go in their own PR.
|
||||
@@ -26,10 +25,15 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
- Fill in the template. Keep the description short and in plain words. If it
|
||||
doesn't fit in a few sentences, the PR is probably too big.
|
||||
- **How it was verified** is required: the commands you ran and their results,
|
||||
and what else the change could affect. UI changes need before/after screenshots;
|
||||
multi-step interactions need a recording.
|
||||
and what else the change could affect. Changes to what users see need before/after
|
||||
screenshots; multi-step interactions need a recording. UI that goes away with a removed
|
||||
feature, copy-only and non-visual changes don't; say what you checked instead.
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
A check on each PR adds `needs-info` when the issue link or the verification
|
||||
section is missing, and removes it once they're filled in.
|
||||
Plugins: pull requests here add or change official plugins (`plugins/official/`)
|
||||
and the plugin API. Community plugins live in their authors' repositories and
|
||||
are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins);
|
||||
see [`plugins/README.md`](plugins/README.md) § 8.1.
|
||||
|
||||
A check on each PR adds `needs-info` when the verification section is missing, and removes it once it's filled in.
|
||||
Generated
+511
-75
File diff suppressed because it is too large.
Load diff
+17
-2
@@ -1,9 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
# Plugins and the guest SDK build for wasm32-wasip2 in their own workspaces.
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.35"
|
||||
version = "0.1.37"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -41,6 +43,8 @@ ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -69,9 +73,9 @@ ropey = "1.6"
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "0.1"
|
||||
ignore = "0.4"
|
||||
base64 = "0.22"
|
||||
globset = "0.4"
|
||||
notify = "8"
|
||||
notify-debouncer-full = "0.6"
|
||||
trash = "5"
|
||||
blake3 = "1"
|
||||
dunce = "1"
|
||||
@@ -81,6 +85,7 @@ zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
grep-regex = "0.1"
|
||||
regex-syntax = "0.8"
|
||||
grep-matcher = "0.1"
|
||||
nucleo-matcher = "0.3"
|
||||
redb = "4"
|
||||
@@ -98,8 +103,12 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
keyring = "4.2"
|
||||
keyring-core = "1"
|
||||
|
||||
# testing
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
@@ -109,8 +118,14 @@ tempfile = "3"
|
||||
[profile.dev]
|
||||
# Keep debug builds snappy while giving dependencies decent speed.
|
||||
opt-level = 0
|
||||
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
|
||||
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
|
||||
debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
# Dependency frames keep function names only. Re-enable one with
|
||||
# `--config 'profile.dev.package.<crate>.debug=true'`.
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="#community">Community</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -45,8 +47,28 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Plugins
|
||||
|
||||
Plugins add actions, completion, quick fixes, tool windows, custom editors,
|
||||
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](plugins/README.md) and
|
||||
the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
|
||||
- WeChat group — scan to join:
|
||||
|
||||
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
|
||||
|
||||
## 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).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
+16
-5
@@ -2,12 +2,13 @@
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059),
|
||||
the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch)
|
||||
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
|
||||
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
|
||||
notices are retained in [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [java/lombok/README.md](java/lombok/README.md).
|
||||
|
||||
## JetBrains IntelliJ Platform icons
|
||||
|
||||
@@ -33,7 +34,8 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` →
|
||||
`fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` →
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`,
|
||||
Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
|
||||
|
||||
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
|
||||
@@ -52,6 +54,15 @@ Their original copyright headers and Apache-2.0 licensing are retained.
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform VCS log graph algorithms
|
||||
|
||||
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
|
||||
graph layout and LinearBek merge linearization algorithms from
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
|
||||
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
|
||||
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
|
||||
@@ -58,6 +58,8 @@ pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
/// How long a probe waits for the agent to answer `initialize`.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long deleting a task waits for its agent to exit before removing the worktree.
|
||||
const EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The agent tasks of one project.
|
||||
pub struct AgentManager {
|
||||
@@ -345,6 +347,14 @@ impl AgentManager {
|
||||
provider::set_builtin_command(&dir, id, command.as_deref())
|
||||
}
|
||||
|
||||
/// Sets the environment variables built-in `id` starts with; empty clears them.
|
||||
pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_env(&dir, id, env)
|
||||
}
|
||||
|
||||
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
|
||||
pub fn reload_providers(&self) {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
@@ -831,7 +841,7 @@ impl AgentManager {
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
task.kill();
|
||||
let live = task.kill();
|
||||
let (isolation, cwd, root) = {
|
||||
let s = task.state.lock();
|
||||
(
|
||||
@@ -843,6 +853,11 @@ impl AgentManager {
|
||||
// A copy of the task (a fork) may work in the same worktree.
|
||||
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
// On Windows the agent's processes lock the folder (their working directory,
|
||||
// open files) until they have exited.
|
||||
if let Some(peer) = live {
|
||||
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
|
||||
}
|
||||
let worktrees = self.shared.paths.worktrees_dir.clone();
|
||||
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
|
||||
}
|
||||
@@ -1597,11 +1612,12 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
fn kill(&self) {
|
||||
/// Stops the agent; returns its connection, to wait for its process to exit.
|
||||
fn kill(&self) -> Option<acp::Conn> {
|
||||
self.cancel_permissions();
|
||||
if let Some(live) = self.live.lock().take() {
|
||||
live.peer.kill();
|
||||
}
|
||||
let live = self.live.lock().take()?;
|
||||
live.peer.kill();
|
||||
Some(live.peer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use ide_core::IpcPath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -357,6 +358,8 @@ pub struct AgentProvider {
|
||||
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
|
||||
/// Whether the command was found (on `PATH`).
|
||||
pub installed: bool,
|
||||
/// Environment variables it starts with (from its agent file).
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Slash commands it offered last time.
|
||||
|
||||
@@ -124,6 +124,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
command_editable: reason.is_none(),
|
||||
command_read_only_reason: reason,
|
||||
installed: installed(&spec.command),
|
||||
env: spec.env.iter().cloned().collect(),
|
||||
config: vec![],
|
||||
commands: vec![],
|
||||
source: spec.source.clone(),
|
||||
@@ -133,6 +134,16 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
/// Overrides built-in `id`'s command through its agent file, preserving other
|
||||
/// customizations. `None` restores the built-in command and removes a redundant file.
|
||||
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
|
||||
}
|
||||
|
||||
/// The environment built-in `id` starts with, through its agent file (empty clears it).
|
||||
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, _| agent.env = env)
|
||||
}
|
||||
|
||||
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
|
||||
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
|
||||
let Some(builtin) = builtin_spec(id) else {
|
||||
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
|
||||
};
|
||||
@@ -152,7 +163,8 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
|
||||
if agent.registry.is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
|
||||
}
|
||||
if command.is_none()
|
||||
change(&mut agent, &builtin);
|
||||
if agent.command == builtin.command
|
||||
&& agent.name.as_deref() == Some(builtin.name.as_str())
|
||||
&& agent.driver == builtin.driver
|
||||
&& agent.args == builtin.args
|
||||
@@ -164,7 +176,6 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
agent.command = command.unwrap_or(&builtin.command).to_owned();
|
||||
agent.write(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -368,6 +379,23 @@ mod tests {
|
||||
assert!(installed("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_env_writes_and_clears_the_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
assert_eq!(describe(&claude).env, env);
|
||||
// The command override and the environment are kept independently.
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
assert!(dir.path().join("claude.toml").is_file());
|
||||
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -76,6 +76,8 @@ pub struct RegistryAgent {
|
||||
pub installed: Option<String>,
|
||||
/// Whether [`Self::version`] is newer than the installed one.
|
||||
pub update: bool,
|
||||
/// An install of it is running (it goes on after the Settings page that started it closes).
|
||||
pub installing: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Type)]
|
||||
@@ -332,6 +334,7 @@ fn visible(
|
||||
distribution,
|
||||
no_checksum,
|
||||
installed,
|
||||
installing: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1109,3 +1109,114 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
/// Deleting a task whose agent still runs in the worktree removes both and leaves no
|
||||
/// process behind, whether it is waiting for permission or idle without session/load.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn deleting_a_task_waits_for_its_agent_to_exit_before_removing_the_worktree() {
|
||||
for no_load in [false, true] {
|
||||
delete_running_agent(no_load).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_running_agent(no_load: bool) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&root)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
|
||||
let agent = Arc::new(FakeAgent { no_load, ..FakeAgent::default() });
|
||||
let pids: Arc<Mutex<Vec<u32>>> = Arc::default();
|
||||
let (fake, started) = (agent.clone(), pids.clone());
|
||||
// The fake agent, served on behalf of a real process that exits only when killed.
|
||||
let connector: Connector = Arc::new(move |_spec, cwd, client, _stderr| {
|
||||
#[cfg(unix)]
|
||||
let mut cmd = {
|
||||
let mut cmd = tokio::process::Command::new("sleep");
|
||||
cmd.arg("30");
|
||||
cmd
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.args(["/D", "/C", "echo ready>agent-ready & ping -n 30 127.0.0.1 >nul"]);
|
||||
cmd
|
||||
};
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.current_dir(cwd).kill_on_drop(true);
|
||||
let child = cmd.spawn()?;
|
||||
started.lock().push(child.id().unwrap());
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
// `connect_with_child` frames with Content-Length.
|
||||
fake.servers.lock().push(Peer::connect(br, bw, fake.clone()));
|
||||
Ok(Peer::connect_with_child(ar, aw, client, child))
|
||||
});
|
||||
let mgr = AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: root.clone(),
|
||||
store_dir: root.join("store"),
|
||||
worktrees_dir: root.join("worktrees"),
|
||||
models_file: root.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(if no_load { "again" } else { "Fix the lexer" }),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Worktree,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, if no_load { AgentStatus::Ready } else { AgentStatus::NeedsInput }).await;
|
||||
let pid = *pids.lock().last().expect("the agent was started");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !task.cwd.0.join("agent-ready").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::rename(&task.cwd.0, task.cwd.0.with_extension("moved")).unwrap_err().raw_os_error(),
|
||||
Some(32),
|
||||
"the running agent really locks its worktree"
|
||||
);
|
||||
}
|
||||
|
||||
mgr.delete(&task.id, true).await.unwrap();
|
||||
assert!(!task.cwd.0.exists());
|
||||
// Killed and reaped: not even a zombie is left.
|
||||
#[cfg(unix)]
|
||||
let alive = std::process::Command::new("kill").args(["-0", &pid.to_string()]).status().unwrap().success();
|
||||
#[cfg(windows)]
|
||||
let alive = {
|
||||
let mut cmd = std::process::Command::new("tasklist");
|
||||
ide_core::process::prepare(&mut cmd).args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]);
|
||||
String::from_utf8_lossy(&cmd.output().unwrap().stdout).contains(&format!("\"{pid}\""))
|
||||
};
|
||||
assert!(!alive, "the agent (pid {pid}) is still running");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
pub(crate) struct Created {
|
||||
pub path: PathBuf,
|
||||
@@ -65,7 +65,27 @@ pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) ->
|
||||
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
|
||||
return Err(err);
|
||||
}
|
||||
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
|
||||
retrying(REMOVE_ATTEMPTS, REMOVE_PAUSE, || std::fs::remove_dir_all(path))
|
||||
.map_err(|e| IdeError::internal(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// On Windows a folder can't be deleted while a process still has a file in it open
|
||||
/// (os error 32): an agent's helper that outlived it, an antivirus scan, the search indexer.
|
||||
/// Those let go within moments, so removing is retried for a few seconds.
|
||||
const REMOVE_ATTEMPTS: u32 = 10;
|
||||
const REMOVE_PAUSE: Duration = Duration::from_millis(300);
|
||||
|
||||
fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
for _ in 1..attempts {
|
||||
match op() {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => std::thread::sleep(pause),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
match op() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
@@ -149,6 +169,54 @@ fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removing_is_retried_while_a_file_is_still_in_use() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
// A sharing violation as Windows reports it (os error 32), a vanished folder, other errors.
|
||||
let in_use = || Error::from_raw_os_error(32);
|
||||
for (fails, outcome) in [(0, true), (3, true), (REMOVE_ATTEMPTS - 1, true), (REMOVE_ATTEMPTS, false)] {
|
||||
let mut calls = 0;
|
||||
let r = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls <= fails { Err(in_use()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(r.is_ok(), outcome, "{fails} failures");
|
||||
assert_eq!(calls, (fails + 1).min(REMOVE_ATTEMPTS));
|
||||
}
|
||||
let mut calls = 0;
|
||||
let gone = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls == 1 { Err(in_use()) } else { Err(Error::from(ErrorKind::NotFound)) }
|
||||
});
|
||||
assert!(gone.is_ok(), "deleted meanwhile: done");
|
||||
assert_eq!(calls, 2);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn removing_waits_for_a_windows_file_lock_to_be_released() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let worktrees = tmp.path().join("wt");
|
||||
let created = create(&repo, &worktrees, "Locked file", None).unwrap();
|
||||
let file = std::fs::OpenOptions::new().read(true).share_mode(0).open(created.path.join("a.txt")).unwrap();
|
||||
assert_eq!(std::fs::remove_file(created.path.join("a.txt")).unwrap_err().raw_os_error(), Some(32));
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
drop(file);
|
||||
});
|
||||
let result = remove(&repo, &worktrees, &created.path);
|
||||
release.join().unwrap();
|
||||
result.unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_branch_safe() {
|
||||
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ide-android"
|
||||
description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Android devices and their logs, driven through the `adb` CLI (like git is
|
||||
//! driven through its CLI).
|
||||
//!
|
||||
//! The Logcat tool window needs two things from a device: that it is there, and
|
||||
//! what it prints. Both come from `adb`, which the Android SDK installs — the
|
||||
//! IDE does not bundle it.
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// `adb devices` is quick unless it starts the daemon, which can take a moment.
|
||||
const DEVICES: Duration = Duration::from_secs(20);
|
||||
/// `adb logcat -c` only truncates the buffers.
|
||||
const CLEAR: Duration = Duration::from_secs(15);
|
||||
/// How much of the buffer to show before following, so opening the window is not empty.
|
||||
const TAIL_LINES: u32 = 200;
|
||||
/// Logcat can print thousands of lines a second: batches keep the IPC channel sane,
|
||||
/// while `IDLE` makes sure a quiet stream still shows its last line promptly.
|
||||
const BATCH_IDLE: Duration = Duration::from_millis(60);
|
||||
const BATCH_MAX: usize = 300;
|
||||
|
||||
fn exe(name: &str) -> String {
|
||||
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
|
||||
}
|
||||
|
||||
/// Android SDK locations, most specific first: the environment, then the usual
|
||||
/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`).
|
||||
fn sdk_roots() -> Vec<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = ["ANDROID_HOME", "ANDROID_SDK_ROOT"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from);
|
||||
if let Some(home) = home.filter(|_| !cfg!(windows)) {
|
||||
roots.push(home.join("Library/Android/sdk"));
|
||||
roots.push(home.join("Android/Sdk"));
|
||||
}
|
||||
if cfg!(windows)
|
||||
&& let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
|
||||
{
|
||||
roots.push(local.join("Android/Sdk"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides.
|
||||
pub fn adb_executable() -> Option<PathBuf> {
|
||||
static ADB: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
ADB.get_or_init(|| {
|
||||
if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) {
|
||||
return Some(explicit);
|
||||
}
|
||||
let name = exe("adb");
|
||||
if let Some(found) =
|
||||
sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file())
|
||||
{
|
||||
return Some(found);
|
||||
}
|
||||
if let Ok(found) = which::which("adb") {
|
||||
return Some(found);
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = if cfg!(windows) {
|
||||
vec![PathBuf::from(r"C:\platform-tools\adb.exe")]
|
||||
} else {
|
||||
["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect()
|
||||
};
|
||||
candidates.extend([
|
||||
PathBuf::from("/opt/android-sdk/platform-tools/adb"),
|
||||
PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"),
|
||||
]);
|
||||
candidates.into_iter().find(|p: &PathBuf| p.is_file())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn executable() -> IdeResult<PathBuf> {
|
||||
adb_executable().ok_or_else(|| {
|
||||
IdeError::unavailable(
|
||||
"adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The directory holding adb, for command lines that expect `adb` on `PATH`.
|
||||
pub fn platform_tools_dir() -> Option<&'static Path> {
|
||||
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref()
|
||||
}
|
||||
|
||||
/// Connected devices, online ones first; starting the adb daemon when needed.
|
||||
pub fn devices() -> IdeResult<Vec<AndroidDevice>> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["devices", "-l"]);
|
||||
let out = process::run(cmd, DEVICES, None)?;
|
||||
if !out.success {
|
||||
let message = out.stderr.trim();
|
||||
return Err(IdeError::unavailable(if message.is_empty() {
|
||||
"adb devices failed".to_owned()
|
||||
} else {
|
||||
format!("adb devices failed: {message}")
|
||||
}));
|
||||
}
|
||||
Ok(parse_devices(&out.stdout))
|
||||
}
|
||||
|
||||
/// Empties the device's log buffers (`adb logcat -c`).
|
||||
pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-c"]);
|
||||
let out = process::run(cmd, CLEAR, Some(cancel))?;
|
||||
if out.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim())))
|
||||
}
|
||||
}
|
||||
|
||||
fn command(adb: &Path, serial: &str, tail: Option<u32>) -> Command {
|
||||
let mut cmd = Command::new(adb);
|
||||
cmd.args(["-s", serial, "logcat", "-v", "threadtime"]);
|
||||
if let Some(lines) = tail {
|
||||
// Recent history first, then follow: `-T <count>` is lines, not an exit.
|
||||
cmd.arg("-T").arg(lines.to_string());
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Follows `serial`'s log until `cancel` fires, handing lines over in batches.
|
||||
/// With `clear`, the buffers are emptied first so the window starts clean.
|
||||
pub fn logcat(
|
||||
serial: &str,
|
||||
clear: bool,
|
||||
cancel: &CancellationToken,
|
||||
on_batch: &mut dyn FnMut(Vec<LogcatLine>),
|
||||
) -> IdeResult<()> {
|
||||
let adb = executable()?;
|
||||
if clear {
|
||||
clear_log(serial, cancel)?;
|
||||
}
|
||||
let mut parse = |line: &str| Some(parse_line(line));
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, Some(TAIL_LINES)),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
// The window closed or switched devices: stopping is the expected end.
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
// `-T` is old (API 21) but adb forwards logcat options it does not know on
|
||||
// very old devices; retry without the history rather than showing nothing.
|
||||
let unsupported = stderr.iter().any(|line| {
|
||||
line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option")
|
||||
});
|
||||
if unsupported {
|
||||
let (ok, stderr) = match process::stream_batched(
|
||||
command(&adb, serial, None),
|
||||
cancel,
|
||||
BATCH_IDLE,
|
||||
BATCH_MAX,
|
||||
&mut parse,
|
||||
on_batch,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if ok || cancel.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(failure(serial, &stderr));
|
||||
}
|
||||
Err(failure(serial, &stderr))
|
||||
}
|
||||
|
||||
fn failure(serial: &str, stderr: &[String]) -> IdeError {
|
||||
let message = stderr.join("\n");
|
||||
IdeError::unavailable(if message.trim().is_empty() {
|
||||
format!("adb logcat on {serial} stopped")
|
||||
} else {
|
||||
format!("adb logcat on {serial} stopped: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adb_candidates_are_absolute() {
|
||||
for root in sdk_roots() {
|
||||
assert!(root.is_absolute(), "{root:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executable_is_absolute_when_found() {
|
||||
if let Some(adb) = adb_executable() {
|
||||
assert!(adb.is_absolute());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! What the Logcat tool window shows: the devices `adb` reports and the lines
|
||||
//! `adb logcat` prints, parsed from its `threadtime` format.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// One entry of `adb devices -l`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AndroidDevice {
|
||||
/// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`.
|
||||
pub serial: String,
|
||||
/// `device` (usable), `offline`, `unauthorized` or `no permissions`.
|
||||
pub state: String,
|
||||
pub model: Option<String>,
|
||||
pub product: Option<String>,
|
||||
/// The device's own name from the build (`ro.product.device`).
|
||||
pub device: Option<String>,
|
||||
pub transport_id: Option<String>,
|
||||
}
|
||||
|
||||
impl AndroidDevice {
|
||||
/// Ready for `logcat` / `install`.
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.state == "device"
|
||||
}
|
||||
|
||||
/// What the picker calls it: the model when known, the serial otherwise.
|
||||
pub fn label(&self) -> &str {
|
||||
self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single-letter logcat priority, in increasing severity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LogcatLevel {
|
||||
Verbose,
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Fatal,
|
||||
/// A line logcat prints outside the message format (`--------- beginning of main`).
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LogcatLevel {
|
||||
pub fn letter(self) -> char {
|
||||
match self {
|
||||
Self::Verbose => 'V',
|
||||
Self::Debug => 'D',
|
||||
Self::Info => 'I',
|
||||
Self::Warn => 'W',
|
||||
Self::Error => 'E',
|
||||
Self::Fatal => 'F',
|
||||
Self::Unknown => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(c: char) -> Self {
|
||||
match c.to_ascii_uppercase() {
|
||||
'V' => Self::Verbose,
|
||||
'D' => Self::Debug,
|
||||
'I' => Self::Info,
|
||||
'W' => Self::Warn,
|
||||
'E' => Self::Error,
|
||||
'F' | 'A' => Self::Fatal,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogcatLine {
|
||||
/// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone).
|
||||
pub time: String,
|
||||
pub pid: i32,
|
||||
pub tid: i32,
|
||||
pub level: LogcatLevel,
|
||||
pub tag: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogcatLine {
|
||||
/// A line logcat printed outside the message format, kept so nothing is lost.
|
||||
fn unparsed(text: &str) -> Self {
|
||||
Self {
|
||||
time: String::new(),
|
||||
pid: 0,
|
||||
tid: 0,
|
||||
level: LogcatLevel::Unknown,
|
||||
tag: String::new(),
|
||||
message: text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included.
|
||||
pub fn parse_devices(output: &str) -> Vec<AndroidDevice> {
|
||||
let mut devices: Vec<AndroidDevice> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim_end();
|
||||
if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') {
|
||||
continue;
|
||||
}
|
||||
let mut fields = line.split_whitespace();
|
||||
let (Some(serial), Some(state)) = (fields.next(), fields.next()) else {
|
||||
continue;
|
||||
};
|
||||
// "no permissions (user in plugdev group; are your udev rules wrong?)"
|
||||
// is the one state that carries spaces; the rest is a `key:value` list.
|
||||
let mut device = AndroidDevice {
|
||||
serial: serial.to_owned(),
|
||||
state: state.to_owned(),
|
||||
model: None,
|
||||
product: None,
|
||||
device: None,
|
||||
transport_id: None,
|
||||
};
|
||||
if device.state == "no" {
|
||||
device.state = "no permissions".to_owned();
|
||||
devices.push(device);
|
||||
continue;
|
||||
}
|
||||
for field in fields {
|
||||
let Some((key, value)) = field.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
match key {
|
||||
"model" => device.model = Some(value.to_owned()),
|
||||
"product" => device.product = Some(value.to_owned()),
|
||||
"device" => device.device = Some(value.to_owned()),
|
||||
"transport_id" => device.transport_id = Some(value.to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
devices.push(device);
|
||||
}
|
||||
// Online first, then by name, so the picker's default is the device in hand.
|
||||
devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial)));
|
||||
devices
|
||||
}
|
||||
|
||||
/// Parses one `logcat -v threadtime` line:
|
||||
///
|
||||
/// ```text
|
||||
/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed
|
||||
/// ```
|
||||
///
|
||||
/// Text that does not follow the format still becomes a line, so a device that
|
||||
/// prints something unexpected does not look like a dead stream.
|
||||
pub fn parse_line(text: &str) -> LogcatLine {
|
||||
let Some(line) = parse_formatted(text) else {
|
||||
return LogcatLine::unparsed(text);
|
||||
};
|
||||
line
|
||||
}
|
||||
|
||||
fn parse_formatted(text: &str) -> Option<LogcatLine> {
|
||||
// "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces.
|
||||
let stamp = text.get(..18)?;
|
||||
if !looks_like_stamp(stamp) {
|
||||
return None;
|
||||
}
|
||||
let mut rest = text.get(18..)?;
|
||||
let pid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let tid: i32 = take_field(&mut rest)?.parse().ok()?;
|
||||
let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?);
|
||||
let tail = rest.trim_start();
|
||||
// "Tag : message": logcat pads the tag to 8 columns, and the tag
|
||||
// itself never contains a colon followed by a space.
|
||||
let (tag, message) = match tail.split_once(": ") {
|
||||
Some((tag, message)) => (tag.trim_end(), message),
|
||||
None => (tail.trim_end(), ""),
|
||||
};
|
||||
Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() })
|
||||
}
|
||||
|
||||
/// Consumes the next space-separated field of `rest`, leaving the remainder.
|
||||
fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> {
|
||||
*rest = rest.trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
let field = &rest[..end];
|
||||
*rest = &rest[end..];
|
||||
Some(field)
|
||||
}
|
||||
|
||||
fn looks_like_stamp(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
bytes.len() == 18
|
||||
&& bytes[2] == b'-'
|
||||
&& bytes[5] == b' '
|
||||
&& bytes[8] == b':'
|
||||
&& bytes[11] == b':'
|
||||
&& bytes[14] == b'.'
|
||||
&& bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_with_attributes() {
|
||||
let out = "\
|
||||
List of devices attached
|
||||
HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1
|
||||
emulator-5554 offline transport_id:2
|
||||
0123456789ABCDEF unauthorized usb:1-1 transport_id:3
|
||||
|
||||
";
|
||||
let devices = parse_devices(out);
|
||||
assert_eq!(devices.len(), 3);
|
||||
// Online first, then by serial.
|
||||
assert_eq!(devices[0].serial, "HT7A1B2C3D4");
|
||||
assert_eq!(devices[0].state, "device");
|
||||
assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone"));
|
||||
assert_eq!(devices[0].transport_id.as_deref(), Some("1"));
|
||||
assert!(devices[0].is_online());
|
||||
assert_eq!(devices[1].serial, "0123456789ABCDEF");
|
||||
assert_eq!(devices[1].state, "unauthorized");
|
||||
assert_eq!(devices[1].label(), "0123456789ABCDEF");
|
||||
assert_eq!(devices[2].serial, "emulator-5554");
|
||||
assert!(!devices[2].is_online());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_adb_devices_when_the_list_is_empty() {
|
||||
assert!(parse_devices("List of devices attached\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_no_permissions() {
|
||||
let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n");
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].state, "no permissions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_threadtime_line() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity");
|
||||
assert_eq!(line.time, "11-04 12:52:39.123");
|
||||
assert_eq!(line.pid, 3048);
|
||||
assert_eq!(line.tid, 3059);
|
||||
assert_eq!(line.level, LogcatLevel::Info);
|
||||
assert_eq!(line.tag, "WindowManager");
|
||||
assert_eq!(line.message, "Displayed activity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_colons_in_the_message() {
|
||||
let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom");
|
||||
assert_eq!(line.tag, "flutter");
|
||||
assert_eq!(line.level, LogcatLevel::Error);
|
||||
assert_eq!(line.message, "java.lang.IllegalStateException: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_lines_outside_the_format() {
|
||||
let line = parse_line("--------- beginning of main");
|
||||
assert_eq!(line.level, LogcatLevel::Unknown);
|
||||
assert_eq!(line.message, "--------- beginning of main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_order_by_severity() {
|
||||
assert!(LogcatLevel::Warn > LogcatLevel::Info);
|
||||
assert!(LogcatLevel::Fatal > LogcatLevel::Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams
|
||||
//! that are handed over in batches (logcat is far too chatty for one message a line).
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled child gets to exit on `SIGTERM` (and flush) before `SIGKILL`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run adb: {e}")))
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
ide_core::output_encoding::decode_tool_output(&buf)
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("adb did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok(Output {
|
||||
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
success: status.success(),
|
||||
})
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Line(bool, String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stdout: bool) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
if !pending.is_empty() {
|
||||
let line = ide_core::output_encoding::decode_tool_output(pending);
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stdout, line.trim_end().to_string()));
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
for &b in &buf[..n] {
|
||||
if b == b'\n' {
|
||||
flush(&mut pending);
|
||||
} else if b != b'\r' {
|
||||
pending.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut pending);
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
fn flush<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
|
||||
if !batch.is_empty() {
|
||||
on_batch(std::mem::take(batch));
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to
|
||||
/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle`
|
||||
/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a
|
||||
/// tail for the error message. Returns whether the child exited successfully.
|
||||
pub(crate) fn stream_batched<T>(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
idle: Duration,
|
||||
max: usize,
|
||||
mut parse: impl FnMut(&str) -> Option<T>,
|
||||
on_batch: &mut dyn FnMut(Vec<T>),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
let mut open = 0;
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(out, tx.clone(), true);
|
||||
open += 1;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(err, tx.clone(), false);
|
||||
open += 1;
|
||||
}
|
||||
drop(tx);
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
let mut batch: Vec<T> = Vec::with_capacity(max);
|
||||
// When the first line of the waiting batch arrived.
|
||||
let mut since = Instant::now();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) };
|
||||
match rx.recv_timeout(wait) {
|
||||
Ok(Event::Line(true, line)) => {
|
||||
if let Some(item) = parse(&line) {
|
||||
if batch.is_empty() {
|
||||
since = Instant::now();
|
||||
}
|
||||
batch.push(item);
|
||||
if batch.len() >= max || since.elapsed() >= idle {
|
||||
flush(&mut batch, on_batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Line(false, line)) => {
|
||||
if !line.trim().is_empty() {
|
||||
stderr_tail.push(line);
|
||||
if stderr_tail.len() > 20 {
|
||||
stderr_tail.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Closed) => open -= 1,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
flush(&mut batch, on_batch);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok((status.success(), stderr_tail))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_steady_stream_is_handed_over_while_it_runs() {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]);
|
||||
let mut sizes = Vec::new();
|
||||
let (ok, _) = stream_batched(
|
||||
cmd,
|
||||
&CancellationToken::new(),
|
||||
Duration::from_millis(60),
|
||||
300,
|
||||
|line| Some(line.to_owned()),
|
||||
&mut |batch| sizes.push(batch.len()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok);
|
||||
assert_eq!(sizes.iter().sum::<usize>(), 20);
|
||||
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Drives a real device through `adb`, checking what the Logcat tool window does:
|
||||
//! that the device list parses, that the stream delivers batches, and that
|
||||
//! cancelling stops both the stream and its `adb` process.
|
||||
//!
|
||||
//! Ignored by default (needs a connected device). Run with
|
||||
//! `OPENIDEA_ADB_DEVICE=<serial> cargo test -p ide-android --test device -- --ignored --nocapture`
|
||||
//! — without the variable, the connected device is used when there is exactly one.
|
||||
|
||||
use ide_android::*;
|
||||
use ide_core::CancellationToken;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn serial() -> Option<String> {
|
||||
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
|
||||
return Some(serial);
|
||||
}
|
||||
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
|
||||
(online.len() == 1).then(|| online[0].serial.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn finds_the_adb_executable() {
|
||||
let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)");
|
||||
println!("adb: {}", adb.display());
|
||||
assert!(adb.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn lists_connected_devices() {
|
||||
let devices = devices().expect("adb devices");
|
||||
for device in &devices {
|
||||
println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id);
|
||||
}
|
||||
assert!(!devices.is_empty(), "no devices connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn streams_batches_and_stops_on_cancel() {
|
||||
let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE");
|
||||
let cancel = CancellationToken::new();
|
||||
let stop = cancel.clone();
|
||||
let received = Arc::new(Mutex::new(Vec::<LogcatLine>::new()));
|
||||
let sink = received.clone();
|
||||
let started = Instant::now();
|
||||
let stream = std::thread::spawn(move || {
|
||||
ide_android::logcat(&serial, false, &stop, &mut |batch| {
|
||||
sink.lock().unwrap().extend(batch);
|
||||
})
|
||||
});
|
||||
// Enough to see the tail seed and a few live lines from a device that idles.
|
||||
std::thread::sleep(Duration::from_secs(4));
|
||||
cancel.cancel();
|
||||
stream.join().unwrap().expect("logcat stops cleanly when cancelled");
|
||||
let lines = received.lock().unwrap();
|
||||
println!("{} lines in {:?}", lines.len(), started.elapsed());
|
||||
for line in lines.iter().rev().take(5) {
|
||||
println!("{:<14} {:>6} {}", line.tag, line.pid, line.message);
|
||||
}
|
||||
assert!(!lines.is_empty(), "no lines within 4s");
|
||||
assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
|
||||
kind: "maven",
|
||||
});
|
||||
}
|
||||
if let Some(g) = &model.gradle
|
||||
if let Some(g) = model.gradle_for_module(root, &module)
|
||||
&& let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module)
|
||||
{
|
||||
let script = cache.join("resources.init.gradle");
|
||||
@@ -163,11 +163,11 @@ mod tests {
|
||||
assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into()));
|
||||
assert_eq!(maven.env["JAVA_HOME"], "/jdk");
|
||||
model.maven = None;
|
||||
model.gradle = Some(crate::GradleModel {
|
||||
model.gradle = vec![crate::GradleModel {
|
||||
command: "/tools/gradlew".into(),
|
||||
projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
}];
|
||||
let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap();
|
||||
assert_eq!(gradle.command, "/tools/gradlew");
|
||||
assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources");
|
||||
|
||||
@@ -2,18 +2,49 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Read;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
|
||||
type Stamp = (u64, SystemTime, Option<String>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
/// Per input: a [`Stamp`] for regular files, `None` for
|
||||
/// directories and inputs that cannot be resolved. The content hash catches external
|
||||
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
|
||||
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
|
||||
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
|
||||
files: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
}
|
||||
|
||||
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
|
||||
/// unreadable file records a sentinel so one permission problem does not drop the whole
|
||||
/// fingerprint (that would just force a normal rebuild).
|
||||
fn content_hash(path: &Path) -> IdeResult<String> {
|
||||
let mut file = match fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = file.read(&mut buffer)?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
fn scan(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
@@ -36,11 +67,12 @@ fn scan(
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(&entry?.path(), files, ancestors, cancel)?;
|
||||
scan(root, &entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
@@ -145,11 +177,11 @@ pub fn launch_fingerprint(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -231,6 +263,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
|
||||
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
|
||||
/// must differ via the content hash, not via `(length, mtime)`.
|
||||
#[test]
|
||||
fn external_edit_preserving_length_and_mtime_still_invalidates() {
|
||||
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);
|
||||
assert!(before.is_some());
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
// Same 5-byte length, different content.
|
||||
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);
|
||||
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::settings::CMakeProfile;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
use parse::{ColUnit, Format, OutputParser, RawMessage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -41,6 +42,8 @@ pub enum BuildSystem {
|
||||
Go,
|
||||
#[serde(rename = "typescript")]
|
||||
TypeScript,
|
||||
#[serde(rename = "cmake")]
|
||||
CMake,
|
||||
}
|
||||
|
||||
/// One command of a build.
|
||||
@@ -144,7 +147,9 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
let rel = rel_str(root, dir);
|
||||
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
|
||||
Some(BuildSystem::Maven)
|
||||
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
|
||||
} else if model.gradle_for_module(root, dir).is_some()
|
||||
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
|
||||
{
|
||||
Some(BuildSystem::Gradle)
|
||||
} else if has(dir, &["Cargo.toml"]) {
|
||||
Some(BuildSystem::Cargo)
|
||||
@@ -152,21 +157,20 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
Some(BuildSystem::Go)
|
||||
} else if has(dir, &["tsconfig.json", "package.json"]) {
|
||||
Some(BuildSystem::TypeScript)
|
||||
} else if has(dir, &["CMakeLists.txt"]) {
|
||||
Some(BuildSystem::CMake)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `root/rel`, without a trailing separator for the root itself.
|
||||
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
|
||||
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
|
||||
/// as other tools (JDT LS) spell it.
|
||||
/// Resolves a workspace-relative or absolute directory with native path separators.
|
||||
fn subdir(root: &Path, rel: &str) -> PathBuf {
|
||||
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
|
||||
root.join(rel).components().collect()
|
||||
}
|
||||
|
||||
fn rel_str(root: &Path, dir: &Path) -> String {
|
||||
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
|
||||
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Directories from `file`'s folder up to `root` (inclusive).
|
||||
@@ -176,11 +180,25 @@ fn ancestors_within<'a>(root: &'a Path, file: &'a Path) -> impl Iterator<Item =
|
||||
}
|
||||
|
||||
/// The build of `root`, or of the module containing `file` (Build Module / Recompile).
|
||||
pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path>) -> IdeResult<BuildPlan> {
|
||||
/// `cmake`: the CMake profiles of the settings (the first one is built), if any.
|
||||
pub fn plan(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
kind: BuildKind,
|
||||
file: Option<&Path>,
|
||||
cmake: Option<&[CMakeProfile]>,
|
||||
) -> IdeResult<BuildPlan> {
|
||||
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
|
||||
let module_dir = match file {
|
||||
Some(f) => {
|
||||
let dir = ancestors_within(root, f)
|
||||
let scope = model
|
||||
.gradle
|
||||
.iter()
|
||||
.flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir))))
|
||||
.filter(|dir| f.starts_with(dir))
|
||||
.max_by_key(|dir| dir.components().count())
|
||||
.unwrap_or_else(|| root.to_path_buf());
|
||||
let dir = ancestors_within(&scope, f)
|
||||
.find(|d| detect_dir(d, model, root).is_some())
|
||||
.ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?;
|
||||
Some(dir.to_path_buf())
|
||||
@@ -190,9 +208,11 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
|
||||
let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf());
|
||||
// A folder of sibling projects has no build file itself; its Maven projects are built one by one.
|
||||
let system = detect_dir(&dir, model, root)
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
})?;
|
||||
let module = module_dir.as_deref().filter(|d| *d != root);
|
||||
let title = match module {
|
||||
Some(d) => format!(
|
||||
@@ -209,6 +229,7 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
|
||||
// Go builds the file's package (a module holds many).
|
||||
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)),
|
||||
};
|
||||
Ok(BuildPlan { system, title, steps })
|
||||
}
|
||||
@@ -255,14 +276,16 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
}
|
||||
|
||||
fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
|
||||
let fallback;
|
||||
let g = match &model.gradle {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?;
|
||||
&fallback
|
||||
}
|
||||
};
|
||||
if module.is_none() && !model.gradle.is_empty() {
|
||||
return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect());
|
||||
}
|
||||
let g = module
|
||||
.and_then(|m| model.gradle_for_module(root, m))
|
||||
.ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?;
|
||||
Ok(gradle_step(root, g, kind, module))
|
||||
}
|
||||
|
||||
fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec<BuildStep> {
|
||||
let prefix = module
|
||||
.and_then(|m| {
|
||||
let rel = rel_str(root, m);
|
||||
@@ -281,7 +304,7 @@ fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option
|
||||
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
|
||||
s.args.extend(tasks);
|
||||
s.env = g.env.clone();
|
||||
Ok(vec![s])
|
||||
vec![s]
|
||||
}
|
||||
|
||||
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
@@ -302,6 +325,50 @@ fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
}
|
||||
}
|
||||
|
||||
/// CLion's Build Project for a CMake project: configures the build directory when it has no
|
||||
/// `CMakeCache.txt` yet, then `cmake --build` (`--clean-first` to rebuild). The directory and
|
||||
/// options come from the CMake profile; without one, a configured directory of the project
|
||||
/// (the first by name), else `build`, as the generated run configurations use.
|
||||
fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> Vec<BuildStep> {
|
||||
let build_dir = match profile {
|
||||
Some(p) if !p.build_dir.trim().is_empty() => p.build_dir.trim().to_owned(),
|
||||
Some(p) => format!("cmake-build-{}", p.build_type.to_lowercase()),
|
||||
None => {
|
||||
let mut configured: Vec<String> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join("CMakeCache.txt").is_file())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
configured.sort();
|
||||
configured.into_iter().next().unwrap_or_else(|| "build".into())
|
||||
}
|
||||
};
|
||||
let build_type = profile.map_or("Debug", |p| p.build_type.as_str());
|
||||
let env = profile.map(|p| p.env.clone()).unwrap_or_default();
|
||||
let mut steps = Vec::new();
|
||||
if !dir.join(&build_dir).join("CMakeCache.txt").is_file() {
|
||||
let mut configure = BuildStep::new("cmake", &["-S", ".", "-B", &build_dir], dir, Format::Gcc);
|
||||
if let Some(p) = profile.filter(|p| !p.generator.trim().is_empty()) {
|
||||
configure.args.extend(["-G".into(), p.generator.clone()]);
|
||||
}
|
||||
configure.args.push(format!("-DCMAKE_BUILD_TYPE={build_type}"));
|
||||
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
|
||||
configure.args.extend(profile.map(|p| p.cmake_args.clone()).unwrap_or_default());
|
||||
configure.env = env.clone();
|
||||
steps.push(configure);
|
||||
}
|
||||
let mut build = BuildStep::new("cmake", &["--build", &build_dir, "--config", build_type], dir, Format::Gcc);
|
||||
if kind == BuildKind::Rebuild {
|
||||
build.args.push("--clean-first".into());
|
||||
}
|
||||
build.args.extend(profile.map(|p| p.build_args.clone()).unwrap_or_default());
|
||||
build.env = env;
|
||||
steps.push(build);
|
||||
steps
|
||||
}
|
||||
|
||||
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)) };
|
||||
@@ -687,7 +754,7 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module))?;
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
if let Some(goal) = step.args.last_mut() {
|
||||
@@ -752,6 +819,80 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(wrapper), "").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
|
||||
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
|
||||
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps.len(), 2);
|
||||
assert_eq!(all.steps[0].cwd, root.join("a"));
|
||||
assert_eq!(all.steps[1].cwd, root.join("b"));
|
||||
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(module.steps.len(), 1);
|
||||
assert_eq!(module.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(module.steps[0].cwd, root.join("b"));
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
// A directly linked build owns its module even when another composite reports it too.
|
||||
let direct = model.gradle[1].projects[0].clone();
|
||||
model.gradle[0].projects.push(direct);
|
||||
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
|
||||
let mut nested = model.gradle[1].projects[1].clone();
|
||||
nested.path = ":b:app".into();
|
||||
model.gradle[0].projects.push(nested);
|
||||
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
|
||||
assert_eq!(nested.steps[0].program, root.join("b").join(wrapper).to_string_lossy());
|
||||
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
model.gradle.clear();
|
||||
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_linked_roots_build_the_project_and_external_source_module() {
|
||||
let wrapper = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
|
||||
let source = external.join("app/src/main/java/App.java");
|
||||
std::fs::write(&source, "class App {}").unwrap();
|
||||
std::fs::write(external.join(wrapper), "").unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
// Linked roots are saved with forward slashes, so on Windows the program mixes separators.
|
||||
assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper));
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
|
||||
let other = base.join("unlinked");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(other.join("build.gradle"), "").unwrap();
|
||||
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Launch outputs use the platform separator throughout, so they compare equal to
|
||||
@@ -796,12 +937,12 @@ mod tests {
|
||||
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&root, "core/src/main/java/A.java", "class A {}");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"), None);
|
||||
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
|
||||
let p = plan(&root, &model, BuildKind::Build, None, None).unwrap();
|
||||
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", "test-compile"]);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java"))).unwrap();
|
||||
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", "-pl", "core", "-am", "clean", "test-compile"]);
|
||||
assert_eq!(p.steps[0].cwd, root);
|
||||
assert_eq!(p.title, "demo: build module 'core'");
|
||||
@@ -810,24 +951,24 @@ mod tests {
|
||||
write(&g, "settings.gradle", "include 'app'");
|
||||
write(&g, "app/build.gradle", "apply plugin: 'java'");
|
||||
let model = crate::load(&g, &tmp.path().join("cache2"), None);
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java")), None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Gradle);
|
||||
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
assert_eq!(
|
||||
plan(&g, &model, BuildKind::Artifacts, None).unwrap().steps[0].args,
|
||||
plan(&g, &model, BuildKind::Artifacts, None, None).unwrap().steps[0].args,
|
||||
["--console=plain", "assemble"]
|
||||
);
|
||||
|
||||
let c = tmp.path().join("c");
|
||||
write(&c, "Cargo.toml", "[package]\nname='c'");
|
||||
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None).unwrap();
|
||||
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Cargo);
|
||||
assert_eq!(p.steps.len(), 2);
|
||||
assert_eq!(p.steps[0].args[0], "clean");
|
||||
|
||||
let go = tmp.path().join("go");
|
||||
write(&go, "go.mod", "module x");
|
||||
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go"))).unwrap();
|
||||
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go")), None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]);
|
||||
assert_eq!(p.steps[0].cwd, go);
|
||||
|
||||
@@ -835,19 +976,51 @@ mod tests {
|
||||
write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#);
|
||||
write(&ts, "tsconfig.json", "{}");
|
||||
write(&ts, "pnpm-lock.yaml", "");
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None).unwrap();
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]);
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None).unwrap();
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
|
||||
assert_eq!(
|
||||
(p.steps[0].program.as_str(), p.steps[0].args.as_slice()),
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
let cm = tmp.path().join("cm");
|
||||
write(&cm, "CMakeLists.txt", "project(app)");
|
||||
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::CMake);
|
||||
assert_eq!(
|
||||
args(&p),
|
||||
[
|
||||
"-S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
|
||||
"--build build --config Debug"
|
||||
],
|
||||
"an unconfigured project is configured first"
|
||||
);
|
||||
write(&cm, "cmake-build-release/CMakeCache.txt", "");
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
|
||||
assert_eq!(
|
||||
args(&p),
|
||||
["--build cmake-build-release --config Debug --clean-first"],
|
||||
"a configured directory is reused"
|
||||
);
|
||||
let profile = CMakeProfile {
|
||||
build_type: "Release".into(),
|
||||
generator: "Ninja".into(),
|
||||
build_args: vec!["-j".into(), "8".into()],
|
||||
..CMakeProfile::default()
|
||||
};
|
||||
let p =
|
||||
plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(std::slice::from_ref(&profile))).unwrap();
|
||||
assert_eq!(args(&p), ["--build cmake-build-release --config Release -j 8"]);
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(&[CMakeProfile::default()])).unwrap();
|
||||
assert_eq!(p.steps[0].args[..4], ["-S", ".", "-B", "cmake-build-debug"]);
|
||||
|
||||
let siblings = tmp.path().join("siblings");
|
||||
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
|
||||
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
|
||||
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
|
||||
let p = plan(&siblings, &model, BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(
|
||||
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
|
||||
@@ -856,7 +1029,7 @@ mod tests {
|
||||
|
||||
let none = tmp.path().join("none");
|
||||
std::fs::create_dir_all(&none).unwrap();
|
||||
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
|
||||
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) enum Format {
|
||||
Tsc,
|
||||
/// `go build`: `./a.go:12:5: msg`.
|
||||
Go,
|
||||
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
|
||||
Gcc,
|
||||
}
|
||||
|
||||
/// What a printed column counts.
|
||||
@@ -296,6 +298,17 @@ fn go_line(line: &str) -> Option<RawMessage> {
|
||||
(!msg.is_empty()).then(|| RawMessage::new(Severity::Error, msg, ColUnit::Byte).at(path, ln, col))
|
||||
}
|
||||
|
||||
/// A GCC / Clang diagnostic line; notes and the context lines after it are left as output.
|
||||
fn gcc_line(line: &str) -> Option<RawMessage> {
|
||||
let (path, ln, col, rest) = path_line_col(line)?;
|
||||
let rest = rest.strip_prefix(':')?.trim_start();
|
||||
let (severity, msg) =
|
||||
[("fatal error:", Severity::Error), ("error:", Severity::Error), ("warning:", Severity::Warning)]
|
||||
.into_iter()
|
||||
.find_map(|(prefix, severity)| rest.strip_prefix(prefix).map(|m| (severity, m.trim())))?;
|
||||
(!msg.is_empty()).then(|| RawMessage::new(severity, msg, ColUnit::Byte).at(path, ln, col))
|
||||
}
|
||||
|
||||
/// rustc's JSON diagnostic (`reason: compiler-message`) → message and its rendered text.
|
||||
fn cargo_json(v: &Value) -> Option<(Option<RawMessage>, String)> {
|
||||
if v.get("reason")?.as_str()? != "compiler-message" {
|
||||
@@ -410,6 +423,7 @@ impl OutputParser {
|
||||
Format::Gradle => javac_header(&line).or_else(|| kotlin_line(&line)),
|
||||
Format::Tsc => tsc_line(&line),
|
||||
Format::Go => go_line(&line),
|
||||
Format::Gcc => gcc_line(&line),
|
||||
Format::Cargo => None,
|
||||
};
|
||||
if let Some(msg) = parsed {
|
||||
@@ -563,6 +577,24 @@ mod tests {
|
||||
assert_eq!(out[2].text, "Cannot find name 'foo'.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcc_errors_and_warnings() {
|
||||
let out = parse(
|
||||
Format::Gcc,
|
||||
"[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o\n\
|
||||
/p/src/main.cpp:12:5: error: use of undeclared identifier 'foo'\n\
|
||||
\x20 12 | foo();\n\
|
||||
/p/src/util.h:3:10: warning: unused variable 'x' [-Wunused-variable]\n\
|
||||
/p/src/main.cpp:4:10: fatal error: 'missing.h' file not found\n\
|
||||
/p/src/main.cpp:2:1: note: in file included from here\n",
|
||||
);
|
||||
assert_eq!(out.len(), 3, "{out:?}");
|
||||
assert_eq!(out[0].severity, Severity::Error);
|
||||
assert_eq!(out[0].text, "use of undeclared identifier 'foo'");
|
||||
assert_eq!(out[1].severity, Severity::Warning);
|
||||
assert_eq!(out[2].text, "'missing.h' file not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_errors() {
|
||||
let out = parse(
|
||||
|
||||
@@ -435,6 +435,17 @@ pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>)
|
||||
|
||||
pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
let dir = build_dir(root)?;
|
||||
load_build(root, dir.strip_prefix(root).unwrap_or(&dir))
|
||||
}
|
||||
|
||||
/// Loads exactly this build, without searching its child folders.
|
||||
pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
|
||||
// Linked builds are saved with forward slashes; rebuild the path so the launcher
|
||||
// and module folders use the platform's separators.
|
||||
let dir: PathBuf = root.join(dir).components().collect();
|
||||
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
|
||||
return None;
|
||||
}
|
||||
let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default();
|
||||
let root_name =
|
||||
settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
@@ -455,6 +466,65 @@ pub fn load(root: &Path) -> Option<GradleModel> {
|
||||
Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() })
|
||||
}
|
||||
|
||||
/// Validates user-selected build folders and stores a stable, deduplicated identity.
|
||||
pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult<Vec<String>> {
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
let path = dunce::canonicalize(root.join(dir))?;
|
||||
if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) {
|
||||
return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display())));
|
||||
}
|
||||
let dir = rel(&root, &path);
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The form `normalize_linked_projects` saves `dir` in, also for a folder that no longer
|
||||
/// exists, so a path the UI sends (`C:\…` on Windows) matches the saved entry.
|
||||
pub fn linked_project_key(root: &Path, dir: &str) -> String {
|
||||
match (dunce::canonicalize(root), dunce::canonicalize(root.join(dir))) {
|
||||
(Ok(root), Ok(path)) => rel(&root, &path),
|
||||
_ => dir.replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Linked build identities without reading their scripts (also used by index hot paths).
|
||||
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
|
||||
match &settings.linked_projects {
|
||||
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
|
||||
Some(dirs) => {
|
||||
let mut out = Vec::new();
|
||||
for dir in dirs {
|
||||
if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) {
|
||||
for dir in dirs {
|
||||
if !out.contains(&dir) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<GradleModel> {
|
||||
linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect()
|
||||
}
|
||||
|
||||
/// A build's task paths are local to it, so sync caches must be local to its identity too.
|
||||
pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf {
|
||||
if build_dir.is_empty() {
|
||||
return cache.to_path_buf();
|
||||
}
|
||||
let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3));
|
||||
cache.join("gradle").join(format!("{hash:016x}"))
|
||||
}
|
||||
|
||||
/// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script.
|
||||
pub fn script_java_version(text: &str) -> Option<u32> {
|
||||
let number = |s: &str| -> Option<u32> {
|
||||
@@ -616,6 +686,65 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_path_from_the_ui_finds_the_saved_link() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
for dir in [root.join("a"), external.clone()] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
let native = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
// Saved by linking; unlinking may send a native, absolute or non-canonical spelling.
|
||||
for (dir, sent) in [
|
||||
("a", native(&root.join("a"))),
|
||||
("a", "a".to_owned()),
|
||||
("a", native(&root.join("a").join("..").join("a"))),
|
||||
("../external", native(&external)),
|
||||
] {
|
||||
let saved = super::normalize_linked_projects(&root, &[dir.into()]).unwrap();
|
||||
assert_eq!(super::linked_project_key(&root, &sent), saved[0], "{sent}");
|
||||
}
|
||||
// A folder deleted since it was linked still matches its saved entry.
|
||||
assert_eq!(super::linked_project_key(&root, r"gone\build"), "gone/build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
|
||||
}
|
||||
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let models = super::load_linked(root, &settings);
|
||||
assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["b", "a"]);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(super::load_linked(root, &settings).is_empty());
|
||||
assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err());
|
||||
assert!(super::normalize_linked_projects(root, &["".into()]).is_err());
|
||||
assert!(super::load_build(root, root).is_none(), "exact loading never picks a child");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn linked_directories_deduplicate_symlinks_and_keep_external_builds() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("build.gradle"), "").unwrap();
|
||||
std::os::unix::fs::symlink(&external, root.join("alias")).unwrap();
|
||||
let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap();
|
||||
assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]);
|
||||
}
|
||||
#[test]
|
||||
fn script_dependencies_read_string_and_map_notations() {
|
||||
let script = r#"
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! A Gradle project's resolved dependencies for the Dependency Analyzer:
|
||||
//! `gradle <project>:dependencies --configuration <name>`, parsed from its tree report.
|
||||
|
||||
use crate::gradle;
|
||||
use crate::process;
|
||||
use crate::sync::{SyncOptions, error_summary};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
/// One line of the report: an occurrence of a dependency where it is requested.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradleDependency {
|
||||
/// `group`, empty for a project dependency.
|
||||
pub group: String,
|
||||
/// Artifact name, or the project path (`:core`) of a project dependency.
|
||||
pub name: String,
|
||||
/// The version asked for here; `None` when a platform or constraint supplies it.
|
||||
pub requested: Option<String>,
|
||||
/// The version Gradle selected (`-> 1.2`), when it differs from the request.
|
||||
pub selected: Option<String>,
|
||||
/// A project of the build (`project :core`, or a module substituted by one).
|
||||
pub project: bool,
|
||||
/// `(*)`: listed with its dependencies earlier in the tree.
|
||||
pub repeated: bool,
|
||||
/// `(c)`: a dependency constraint rather than a dependency.
|
||||
pub constraint: bool,
|
||||
/// `(n)` / `FAILED`: not resolved.
|
||||
pub unresolved: bool,
|
||||
pub children: Vec<GradleDependency>,
|
||||
}
|
||||
|
||||
/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`).
|
||||
pub fn parse_report(output: &str) -> Vec<GradleDependency> {
|
||||
let mut roots: Vec<GradleDependency> = Vec::new();
|
||||
// Path of child indexes from the roots to the last node of each depth.
|
||||
let mut path: Vec<usize> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let Some((depth, text)) = tree_line(line) else { continue };
|
||||
let node = parse_entry(text);
|
||||
path.truncate(depth);
|
||||
if depth > path.len() {
|
||||
continue; // A line deeper than its parent allows: not part of a well-formed tree.
|
||||
}
|
||||
let siblings = if depth == 0 {
|
||||
&mut roots
|
||||
} else {
|
||||
let mut list = &mut roots;
|
||||
for &i in &path[..depth - 1] {
|
||||
list = &mut list[i].children;
|
||||
}
|
||||
let Some(parent) = list.get_mut(path[depth - 1]) else { continue };
|
||||
&mut parent.children
|
||||
};
|
||||
siblings.push(node);
|
||||
path.push(siblings.len() - 1);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// `| \--- g:a:1.0` → (1, "g:a:1.0").
|
||||
fn tree_line(line: &str) -> Option<(usize, &str)> {
|
||||
let mut rest = line;
|
||||
let mut depth = 0;
|
||||
loop {
|
||||
if let Some(r) = rest.strip_prefix("+--- ").or_else(|| rest.strip_prefix("\\--- ")) {
|
||||
return Some((depth, r.trim()));
|
||||
}
|
||||
rest = rest.strip_prefix("| ").or_else(|| rest.strip_prefix(" "))?;
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_entry(text: &str) -> GradleDependency {
|
||||
let mut d = GradleDependency::default();
|
||||
let mut text = text.trim();
|
||||
loop {
|
||||
if let Some(t) = text.strip_suffix(" (*)") {
|
||||
d.repeated = true;
|
||||
text = t;
|
||||
} else if let Some(t) = text.strip_suffix(" (c)") {
|
||||
d.constraint = true;
|
||||
text = t;
|
||||
} else if let Some(t) = text.strip_suffix(" (n)").or_else(|| text.strip_suffix(" FAILED")) {
|
||||
d.unresolved = true;
|
||||
text = t;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
text = text.trim_end();
|
||||
}
|
||||
let (left, right) = match text.split_once(" -> ") {
|
||||
Some((l, r)) => (l.trim(), Some(r.trim())),
|
||||
None => (text, None),
|
||||
};
|
||||
if let Some(project) = left.strip_prefix("project ") {
|
||||
d.project = true;
|
||||
d.name = project_path(project);
|
||||
} else {
|
||||
let mut parts = left.splitn(3, ':');
|
||||
d.group = parts.next().unwrap_or_default().to_owned();
|
||||
d.name = parts.next().unwrap_or_default().to_owned();
|
||||
d.requested = parts.next().filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
}
|
||||
if let Some(right) = right {
|
||||
if let Some(project) = right.strip_prefix("project ") {
|
||||
// A module substituted by a project of the build.
|
||||
d.project = true;
|
||||
d.selected = Some(project_path(project));
|
||||
} else {
|
||||
d.selected = Some(right.to_owned());
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// `:core`, or `':core'` as Gradle 9 quotes it.
|
||||
fn project_path(text: &str) -> String {
|
||||
let t = text.trim();
|
||||
t.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')).unwrap_or(t).to_owned()
|
||||
}
|
||||
|
||||
/// Runs the `dependencies` report of `project` (a Gradle path, `:` for the root) for
|
||||
/// `configuration` and returns its tree.
|
||||
pub fn gradle_dependencies(
|
||||
root: &Path,
|
||||
model: &gradle::GradleModel,
|
||||
options: &SyncOptions,
|
||||
project: &str,
|
||||
configuration: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<GradleDependency>> {
|
||||
if !configuration.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') || configuration.is_empty() {
|
||||
return Err(IdeError::invalid(format!("not a configuration name: {configuration}")));
|
||||
}
|
||||
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
|
||||
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
|
||||
}
|
||||
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
|
||||
let mut args: Vec<String> =
|
||||
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
|
||||
if options.offline {
|
||||
args.push("--offline".into());
|
||||
}
|
||||
let out = process::run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_, _| {},
|
||||
)?;
|
||||
if !out.success {
|
||||
return Err(IdeError::unavailable(error_summary(&out.text)));
|
||||
}
|
||||
Ok(parse_report(&out.text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dep(group: &str, name: &str, requested: Option<&str>) -> GradleDependency {
|
||||
GradleDependency {
|
||||
group: group.into(),
|
||||
name: name.into(),
|
||||
requested: requested.map(Into::into),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_tree_with_selected_versions_and_markers() {
|
||||
let report = "\
|
||||
------------------------------------------------------------
|
||||
Project ':app'
|
||||
------------------------------------------------------------
|
||||
|
||||
runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
+--- org.springframework.boot:spring-boot-starter-web -> 3.2.0
|
||||
| +--- org.springframework.boot:spring-boot-starter:3.2.0
|
||||
| | \\--- org.yaml:snakeyaml:2.2
|
||||
| \\--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.15.3 (*)
|
||||
+--- project ':core'
|
||||
| \\--- com.google.guava:guava:{strictly 32.1.3-jre} -> 32.1.3-jre (c)
|
||||
+--- org.example:legacy:1.0 -> project :legacy
|
||||
\\--- org.missing:gone:9 FAILED
|
||||
|
||||
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
|
||||
(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.
|
||||
";
|
||||
let tree = parse_report(report);
|
||||
assert_eq!(tree.len(), 4);
|
||||
let web = &tree[0];
|
||||
assert_eq!((web.group.as_str(), web.name.as_str()), ("org.springframework.boot", "spring-boot-starter-web"));
|
||||
assert_eq!((web.requested.as_deref(), web.selected.as_deref()), (None, Some("3.2.0")));
|
||||
assert_eq!(web.children.len(), 2);
|
||||
assert_eq!(web.children[0].children, vec![dep("org.yaml", "snakeyaml", Some("2.2"))]);
|
||||
let databind = &web.children[1];
|
||||
assert_eq!((databind.requested.as_deref(), databind.selected.as_deref()), (Some("2.15.0"), Some("2.15.3")));
|
||||
assert!(databind.repeated);
|
||||
let core = &tree[1];
|
||||
assert!(core.project && core.group.is_empty());
|
||||
assert_eq!(core.name, ":core");
|
||||
let guava = &core.children[0];
|
||||
assert!(guava.constraint);
|
||||
assert_eq!(guava.requested.as_deref(), Some("{strictly 32.1.3-jre}"));
|
||||
let legacy = &tree[2];
|
||||
assert!(legacy.project);
|
||||
assert_eq!((legacy.name.as_str(), legacy.selected.as_deref()), ("legacy", Some(":legacy")));
|
||||
assert!(tree[3].unresolved);
|
||||
assert_eq!(tree[3].requested.as_deref(), Some("9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_configuration_has_no_dependencies() {
|
||||
assert!(
|
||||
parse_report("compileClasspath - Compile classpath for source set 'main'.\nNo dependencies\n").is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_names_that_are_not_configurations_or_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = SyncOptions::default();
|
||||
let cancel = CancellationToken::new();
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
|
||||
);
|
||||
assert!(
|
||||
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runs_the_report_of_the_project_and_configuration() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let launcher = root.join("fake-gradle");
|
||||
std::fs::write(&launcher, "#!/bin/sh\necho \"$@\" > args\necho '\\--- org.a:b:1.0'\n").unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options = SyncOptions {
|
||||
gradle_command: Some(launcher.to_string_lossy().into_owned()),
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree = gradle_dependencies(
|
||||
root,
|
||||
&gradle::load(root).unwrap(),
|
||||
&options,
|
||||
":app",
|
||||
"testRuntimeClasspath",
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("args")).unwrap().trim(),
|
||||
"--console=plain -q :app:dependencies --configuration testRuntimeClasspath --offline"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ pub fn build(
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outcome> {
|
||||
std::fs::create_dir_all(generation)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?;
|
||||
if plan.system != compile::BuildSystem::Maven {
|
||||
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
|
||||
}
|
||||
|
||||
+94
-16
@@ -8,6 +8,7 @@
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
mod gradle_deps;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
@@ -17,8 +18,11 @@ pub mod web;
|
||||
mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use gradle_deps::{GradleDependency, gradle_dependencies};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
|
||||
pub use sync::{
|
||||
SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -36,13 +40,31 @@ pub enum BuildTool {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BuildModel {
|
||||
pub maven: Option<MavenModel>,
|
||||
pub gradle: Option<GradleModel>,
|
||||
#[serde(default)]
|
||||
pub gradle: Vec<GradleModel>,
|
||||
}
|
||||
|
||||
impl BuildModel {
|
||||
/// Prefer the deepest linked build owning the module over a composite including it.
|
||||
pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> {
|
||||
let module = root.join(module);
|
||||
self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| {
|
||||
let dir = root.join(&g.dir);
|
||||
std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`.
|
||||
/// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one.
|
||||
pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option<PathBuf>) -> BuildModel {
|
||||
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
|
||||
load_linked(
|
||||
root,
|
||||
cache_dir,
|
||||
maven_repo,
|
||||
&ide_core::settings::MavenSettings::default(),
|
||||
&ide_core::settings::GradleSettings::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
|
||||
@@ -51,30 +73,66 @@ pub fn load_linked(
|
||||
cache_dir: &Path,
|
||||
maven_repo: Option<PathBuf>,
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
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);
|
||||
// The user settings' profiles can be chosen too (`-P` applies to them as well).
|
||||
for p in maven::user_settings_file(root, settings).map(|f| maven::settings_profiles(&f)).unwrap_or_default() {
|
||||
if !m.profiles.iter().any(|x| x.id == p.id) {
|
||||
m.profiles.push(p);
|
||||
}
|
||||
}
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
});
|
||||
let gradle = gradle::load(root).map(|mut g| {
|
||||
sync::apply_gradle_cache(root, cache_dir, &mut g);
|
||||
g
|
||||
});
|
||||
let gradle = gradle::load_linked(root, gradle_settings)
|
||||
.into_iter()
|
||||
.map(|mut g| {
|
||||
sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g);
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
BuildModel { maven, gradle }
|
||||
}
|
||||
|
||||
/// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings):
|
||||
/// extra content roots, as IDEA lists module roots beside the project folder.
|
||||
/// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths.
|
||||
pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str()))
|
||||
pub fn gradle_external_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<PathBuf> {
|
||||
let builds = gradle::linked_dirs(root, settings);
|
||||
let dirs: Vec<String> = builds
|
||||
.iter()
|
||||
.flat_map(|dir| {
|
||||
std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain(
|
||||
sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir))
|
||||
.iter()
|
||||
.map(|p| p.dir.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
gradle::external_roots(root, dirs.iter().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Source, resource and build folders of the last Gradle sync, absolute.
|
||||
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
|
||||
pub fn gradle_source_roots(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
) -> Vec<(PathBuf, gradle::ReportedRoot)> {
|
||||
let reported = gradle::linked_dirs(root, settings)
|
||||
.iter()
|
||||
.flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone())
|
||||
.collect::<Vec<_>>();
|
||||
gradle::reported_source_roots(&reported)
|
||||
}
|
||||
|
||||
/// A library the project depends on, as `groupId:artifactId:version`.
|
||||
@@ -118,7 +176,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(g) = &model.gradle {
|
||||
for g in &model.gradle {
|
||||
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)| {
|
||||
@@ -144,6 +202,26 @@ pub fn affects_build(rel: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("workspace");
|
||||
let external = temp.path().join("external");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::create_dir(&external).unwrap();
|
||||
std::fs::write(external.join("settings.gradle"), "").unwrap();
|
||||
let mut settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
gradle_external_roots(&root, &temp.path().join("cache"), &settings),
|
||||
[dunce::canonicalize(&external).unwrap()]
|
||||
);
|
||||
settings.linked_projects = Some(vec![]);
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -155,7 +233,7 @@ mod tests {
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let m = load(&root, &cache, None);
|
||||
assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml");
|
||||
assert_eq!(m.gradle.unwrap().projects[0].path, ":");
|
||||
assert_eq!(m.gradle[0].projects[0].path, ":");
|
||||
assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts")));
|
||||
assert!(!affects_build(Path::new("src/Main.java")));
|
||||
}
|
||||
@@ -202,7 +280,7 @@ mod tests {
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let gradle = gradle::load(root);
|
||||
let gradle = gradle::load(root).into_iter().collect();
|
||||
let model = BuildModel { maven: Some(maven), gradle };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
|
||||
@@ -460,6 +460,8 @@ const MAX_PARENT_DEPTH: usize = 10;
|
||||
|
||||
struct Resolver {
|
||||
local_repo: Option<PathBuf>,
|
||||
/// The chosen profiles (`-P` entries; `!id` / `-id` deactivates).
|
||||
profiles: Vec<String>,
|
||||
poms: HashMap<PathBuf, Option<RawPom>>,
|
||||
contexts: HashMap<PathBuf, Inherited>,
|
||||
}
|
||||
@@ -470,6 +472,41 @@ pub(crate) fn default_local_repo() -> Option<PathBuf> {
|
||||
local_repository_of(&m2.join("settings.xml")).or(Some(m2.join("repository")))
|
||||
}
|
||||
|
||||
/// The user `settings.xml` a build reads: the configured one (`~/` and project-relative
|
||||
/// paths allowed), else `~/.m2/settings.xml`.
|
||||
pub fn user_settings_file(root: &Path, settings: &ide_core::settings::MavenSettings) -> Option<PathBuf> {
|
||||
let configured = settings.user_settings.trim();
|
||||
if configured.is_empty() {
|
||||
return Some(dirs::home_dir()?.join(".m2").join("settings.xml"));
|
||||
}
|
||||
let path = match configured.strip_prefix("~/") {
|
||||
Some(rest) => dirs::home_dir()?.join(rest),
|
||||
None => PathBuf::from(configured),
|
||||
};
|
||||
Some(if path.is_absolute() { path } else { root.join(path) })
|
||||
}
|
||||
|
||||
/// The profiles a `settings.xml` defines, for the Profiles list beside the POMs' own (as
|
||||
/// 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) = std::fs::read_to_string(settings).ok().and_then(|text| xml::parse(&text)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let active: Vec<String> =
|
||||
root.list("activeProfiles", "activeProfile").iter().map(|e| e.text.trim().to_owned()).collect();
|
||||
let mut profiles: Vec<MavenProfile> = Vec::new();
|
||||
for profile in root.list("profiles", "profile") {
|
||||
let Some(id) = profile.text_of("id") else { continue };
|
||||
if profiles.iter().any(|p| p.id == id) {
|
||||
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 });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
|
||||
/// `<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> {
|
||||
@@ -484,8 +521,16 @@ pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
fn new(local_repo: Option<PathBuf>) -> Self {
|
||||
Self { local_repo, poms: HashMap::new(), contexts: HashMap::new() }
|
||||
fn new(local_repo: Option<PathBuf>, profiles: &[String]) -> Self {
|
||||
Self { local_repo, profiles: profiles.to_vec(), poms: HashMap::new(), contexts: HashMap::new() }
|
||||
}
|
||||
|
||||
/// The active profiles of a POM: the chosen ones, and the `activeByDefault` ones not turned
|
||||
/// off while none of the POM's other profiles is chosen (Maven's rule).
|
||||
fn active<'a>(&self, profiles: &'a [RawProfile]) -> impl Iterator<Item = &'a RawProfile> {
|
||||
let chosen = |id: &str| profile_choice(&self.profiles, id);
|
||||
let any_chosen = profiles.iter().any(|p| chosen(&p.id) == Some(true));
|
||||
profiles.iter().filter(move |p| chosen(&p.id).unwrap_or(p.active_by_default && !any_chosen))
|
||||
}
|
||||
|
||||
fn pom(&mut self, path: &Path) -> Option<RawPom> {
|
||||
@@ -550,7 +595,7 @@ impl Resolver {
|
||||
if pom.compiler_release.is_some() {
|
||||
ctx.compiler_release = pom.compiler_release.clone();
|
||||
}
|
||||
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
||||
for p in self.active(&pom.profiles) {
|
||||
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
|
||||
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
|
||||
merge_plugins(&mut ctx.plugins, &p.plugins);
|
||||
@@ -666,7 +711,7 @@ pub(crate) fn launch_inputs(
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo), &[]);
|
||||
let mut out = Vec::new();
|
||||
for project in projects {
|
||||
let dir = root.join(&project.dir);
|
||||
@@ -1055,12 +1100,77 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
load_with_repo(root, default_local_repo())
|
||||
}
|
||||
|
||||
/// [`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.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo);
|
||||
load_with(root, local_repo, &[])
|
||||
}
|
||||
|
||||
/// The `-P` entries' choice for profile `id`: on, off (`!id` / `-id`), or none (the last wins).
|
||||
fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
|
||||
chosen.iter().rev().find_map(|c| {
|
||||
let c = c.trim();
|
||||
if c == id {
|
||||
Some(true)
|
||||
} else if c.strip_prefix('!').or_else(|| c.strip_prefix('-')) == Some(id) {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[String]) -> MavenModel {
|
||||
load_with_linked(root, local_repo, profiles, &[])
|
||||
}
|
||||
|
||||
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
|
||||
fn linked_poms(root: &Path, linked: &[String]) -> Vec<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for pom in linked {
|
||||
let pom = normalized_pom(pom);
|
||||
if pom.is_empty() || pom.starts_with("../") || pom == ".." {
|
||||
continue;
|
||||
}
|
||||
let path = root.join(&pom);
|
||||
let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base));
|
||||
if path.is_file() && inside && !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `root` holds a Maven build or the settings link one inside it.
|
||||
pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool {
|
||||
is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty()
|
||||
}
|
||||
|
||||
/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not) and the
|
||||
/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent
|
||||
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
|
||||
pub fn load_with_linked(
|
||||
root: &Path,
|
||||
local_repo: Option<PathBuf>,
|
||||
profiles: &[String],
|
||||
linked: &[String],
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
// (pom, aggregated)
|
||||
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
|
||||
let mut tops = top_level_poms(root);
|
||||
for pom in linked_poms(root, linked) {
|
||||
if !tops.contains(&pom) {
|
||||
tops.push(pom);
|
||||
}
|
||||
}
|
||||
// (pom, aggregated); the root reactor and its modules come before linked POMs.
|
||||
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
@@ -1076,7 +1186,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
let i = |s: &str| interpolate(s, &props);
|
||||
|
||||
let mut module_names: Vec<String> = pom.modules.clone();
|
||||
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
||||
for p in resolver.active(&pom.profiles) {
|
||||
module_names.extend(p.modules.iter().cloned());
|
||||
}
|
||||
let mut modules: Vec<PathBuf> = Vec::new();
|
||||
@@ -1182,7 +1292,7 @@ impl UnlinkedProjects {
|
||||
if settings.unlinked_projects.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self::from_model(&load(root), settings)
|
||||
Self::from_model(&load_linked(root, settings), settings)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
|
||||
@@ -1461,6 +1571,37 @@ mod tests {
|
||||
assert_eq!(p.prefix, "checkstyle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_are_listed_with_the_active_ones_checked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("settings.xml");
|
||||
std::fs::write(
|
||||
&file,
|
||||
r#"<settings>
|
||||
<profiles>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><id>jdk21</id><activation><activeByDefault>true</activeByDefault></activation></profile>
|
||||
<profile><id>release</id></profile>
|
||||
<profile><id>nexus</id></profile>
|
||||
<profile><activation/></profile>
|
||||
</profiles>
|
||||
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
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());
|
||||
|
||||
let mut s = ide_core::settings::MavenSettings::default();
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join(".m2/settings.xml")));
|
||||
s.user_settings = "conf/settings.xml".into();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(tmp.path().join("conf/settings.xml")));
|
||||
s.user_settings = "~/work/settings.xml".into();
|
||||
assert_eq!(user_settings_file(tmp.path(), &s), Some(home.join("work/settings.xml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_names_the_repository_to_read_goals_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1699,6 +1840,95 @@ mod tests {
|
||||
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
|
||||
}
|
||||
|
||||
/// Add Maven Projects: a POM inside the project that no reactor lists becomes an
|
||||
/// independent top-level project; one the root reactor lists stays its module.
|
||||
#[test]
|
||||
fn linked_poms_outside_the_reactor_are_top_level_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>main</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>core</module></modules></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"core/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>core</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"stub/pom.xml",
|
||||
"<project><groupId>demo</groupId><artifactId>stub</artifactId><version>1.0</version></project>",
|
||||
);
|
||||
let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]);
|
||||
let linked: Vec<String> =
|
||||
["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into();
|
||||
assert_eq!(
|
||||
top(&load_with_linked(root, None, &[], &linked)),
|
||||
[("".into(), false), ("core".into(), true), ("stub".into(), false)]
|
||||
);
|
||||
|
||||
// Deeper than the folder search reaches: only the link makes it a Maven project.
|
||||
let deep = tempfile::tempdir().unwrap();
|
||||
write(deep.path(), "a/b/c/tool/pom.xml", "<project><artifactId>tool</artifactId></project>");
|
||||
let settings = ide_core::settings::MavenSettings {
|
||||
linked_projects: vec!["a/b/c/tool/pom.xml".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!has_projects(deep.path(), &Default::default()));
|
||||
assert!(has_projects(deep.path(), &settings));
|
||||
assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_profiles_bring_their_modules_and_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
|
||||
<modules><module>core</module></modules>
|
||||
<profiles>
|
||||
<profile><id>flink-1.17</id><activation><activeByDefault>true</activeByDefault></activation>
|
||||
<modules><module>flink-1.17</module></modules></profile>
|
||||
<profile><id>flink-1.20</id><modules><module>flink-1.20</module></modules>
|
||||
<dependencies><dependency><groupId>org.apache.flink</groupId><artifactId>flink-core</artifactId><version>1.20.0</version></dependency></dependencies>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>"#,
|
||||
);
|
||||
for m in ["core", "flink-1.17", "flink-1.20"] {
|
||||
write(
|
||||
root,
|
||||
&format!("{m}/pom.xml"),
|
||||
&format!(
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent><artifactId>{m}</artifactId></project>"
|
||||
),
|
||||
);
|
||||
}
|
||||
let dirs = |m: &MavenModel| m.projects.iter().map(|p| p.dir.clone()).collect::<Vec<_>>();
|
||||
let flink = |m: &MavenModel, dir: &str| {
|
||||
m.projects.iter().find(|p| p.dir == dir).unwrap().dependencies.iter().any(|d| d.artifact_id == "flink-core")
|
||||
};
|
||||
|
||||
let default = load_with(root, None, &[]);
|
||||
assert_eq!(dirs(&default), ["", "core", "flink-1.17"]);
|
||||
assert!(!flink(&default, "core"));
|
||||
|
||||
// Choosing another profile of the POM turns its activeByDefault one off (as `-P` does).
|
||||
let chosen = load_with(root, None, &["flink-1.20".into()]);
|
||||
assert_eq!(dirs(&chosen), ["", "core", "flink-1.20"]);
|
||||
assert!(flink(&chosen, "core"), "a module inherits the chosen profile's dependencies");
|
||||
|
||||
let both = load_with(root, None, &["flink-1.20".into(), "flink-1.17".into()]);
|
||||
assert_eq!(dirs(&both), ["", "core", "flink-1.17", "flink-1.20"]);
|
||||
assert_eq!(dirs(&load_with(root, None, &["!flink-1.17".into()])), ["", "core"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_of_a_parent_pom_are_inherited() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -283,7 +283,12 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load(root);
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.profiles,
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache = MavenCache {
|
||||
@@ -315,7 +320,10 @@ pub fn sync_maven(
|
||||
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
|
||||
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
|
||||
let _ = std::fs::remove_file(&effective);
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
|
||||
// `-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()];
|
||||
args.extend(options.maven_options.iter().cloned());
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
@@ -370,8 +378,25 @@ pub fn sync_gradle(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
sync_gradle_model(root, cache_dir, &model, options, cancel, progress)
|
||||
}
|
||||
|
||||
/// Syncs this exact linked build; another build's task paths and cache cannot interfere.
|
||||
pub fn sync_gradle_model(
|
||||
root: &Path,
|
||||
cache_dir: &Path,
|
||||
model: &GradleModel,
|
||||
options: &SyncOptions,
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut model = model.clone();
|
||||
let cache_dir = gradle::cache_dir(cache_dir, &model.dir);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
let init_script = cache_dir.join("openidea-model.gradle");
|
||||
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
|
||||
let mut args: Vec<String> = vec![
|
||||
@@ -386,14 +411,24 @@ pub fn sync_gradle(
|
||||
args.push("--offline".into());
|
||||
}
|
||||
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
|
||||
let out = run(
|
||||
let out = match run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_| {},
|
||||
)?;
|
||||
) {
|
||||
Ok(out) => out,
|
||||
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
|
||||
Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => {
|
||||
Output { success: false, text: error.message }
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() };
|
||||
// Hash the build files of the reported projects, or the model is stale right after the sync.
|
||||
gradle::merge_reported_projects(root, &mut model, &projects);
|
||||
@@ -404,13 +439,128 @@ pub fn sync_gradle(
|
||||
tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() },
|
||||
projects,
|
||||
};
|
||||
write_cache(cache_dir, GRADLE_CACHE, &cache)
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "").unwrap();
|
||||
}
|
||||
let wrapper = root.join("b/gradlew");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut models = gradle::load_linked(root, &settings);
|
||||
models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned();
|
||||
let cache = root.join("cache");
|
||||
let run = |model: &GradleModel| {
|
||||
sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
};
|
||||
for model in &models {
|
||||
run(model).unwrap();
|
||||
}
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy");
|
||||
let bad_wrapper = root.join("a/gradlew");
|
||||
std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
models[0].command = bad_wrapper.to_string_lossy().into_owned();
|
||||
run(&models[0]).unwrap();
|
||||
let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start"));
|
||||
assert!(loaded.gradle[1].sync_error.is_none());
|
||||
let blocked_cache = root.join("blocked-cache");
|
||||
std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap();
|
||||
assert!(
|
||||
sync_gradle_model(
|
||||
root,
|
||||
&blocked_cache,
|
||||
&models[0],
|
||||
&SyncOptions::default(),
|
||||
&CancellationToken::new(),
|
||||
&|_| {}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
let build = root.join(dir);
|
||||
std::fs::create_dir_all(build.join("app")).unwrap();
|
||||
std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(build.join("app/build.gradle"), "").unwrap();
|
||||
let report = serde_json::json!({"path": ":app", "dir": build.join("app")});
|
||||
let launcher = build.join("gradlew");
|
||||
std::fs::write(
|
||||
&launcher,
|
||||
format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"),
|
||||
).unwrap();
|
||||
std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let cache = root.join("cache");
|
||||
let models = gradle::load_linked(root, &settings);
|
||||
for model in &models {
|
||||
let options = SyncOptions {
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]),
|
||||
..Default::default()
|
||||
};
|
||||
sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(),
|
||||
format!("/jdk-{}", model.dir)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(),
|
||||
root.join(&model.dir).canonicalize().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings);
|
||||
let model = load();
|
||||
assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa");
|
||||
assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb");
|
||||
assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b"));
|
||||
let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap();
|
||||
std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap();
|
||||
sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {})
|
||||
.unwrap();
|
||||
let model = load();
|
||||
assert!(model.gradle[0].sync_error.is_none());
|
||||
assert!(model.gradle[1].sync_error.is_some());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err());
|
||||
assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before);
|
||||
let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() };
|
||||
assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty());
|
||||
assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn gradle_sync_uses_resolved_command_and_environment() {
|
||||
@@ -598,7 +748,7 @@ mod tests {
|
||||
write(&projects);
|
||||
let first = reported_gradle_projects(&cache_dir);
|
||||
assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again");
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]);
|
||||
assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [billing]);
|
||||
write(&projects[..1]);
|
||||
assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read");
|
||||
std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap();
|
||||
@@ -680,4 +830,25 @@ mod tests {
|
||||
assert_eq!(error_summary(text), "Failed to execute goal");
|
||||
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn maven_sync_runs_the_daemon_command_serially() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("p");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
|
||||
.unwrap();
|
||||
let launcher = tmp.path().join("fake-mvnd");
|
||||
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$@\" > mvnd-args\n").unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options =
|
||||
SyncOptions { maven_command: Some(launcher.to_string_lossy().into_owned()), ..Default::default() };
|
||||
sync_maven(&root, &tmp.path().join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
|
||||
let args = std::fs::read_to_string(root.join("mvnd-args")).unwrap();
|
||||
// `-T1` pins the daemon serial so the dependency trees stay parseable.
|
||||
assert!(args.contains("-T1"), "args: {args}");
|
||||
assert!(args.contains("help:effective-pom"), "args: {args}");
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(gradle) = &model.gradle {
|
||||
for gradle in &model.gradle {
|
||||
for p in &gradle.projects {
|
||||
let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default();
|
||||
if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) {
|
||||
out.push(WebArtifact {
|
||||
id: format!("gradle:{}", p.path),
|
||||
id: gradle_artifact_id(gradle, &p.path),
|
||||
name: p.name.clone(),
|
||||
module: p.dir.clone(),
|
||||
tool: "gradle".into(),
|
||||
@@ -42,6 +42,14 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
|
||||
out
|
||||
}
|
||||
|
||||
fn gradle_artifact_id(build: &crate::GradleModel, path: &str) -> String {
|
||||
if build.dir.is_empty() {
|
||||
format!("gradle:{path}")
|
||||
} else {
|
||||
format!("gradle:{}", serde_json::to_string(&(&build.dir, path)).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebBuild {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
@@ -150,9 +158,14 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
if let Some(legacy_path) = id.strip_prefix("gradle:") {
|
||||
let identity = serde_json::from_str::<(String, String)>(legacy_path).ok();
|
||||
let (g, path) = match &identity {
|
||||
Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()),
|
||||
None => (model.gradle.first(), legacy_path),
|
||||
};
|
||||
let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let script = cache.join("war.init.gradle");
|
||||
@@ -186,6 +199,28 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
}
|
||||
let settings = ide_core::settings::GradleSettings {
|
||||
linked_projects: Some(vec!["a".into(), "b".into()]),
|
||||
..Default::default()
|
||||
};
|
||||
let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
|
||||
let artifacts = super::artifacts(root, &model);
|
||||
assert_eq!(artifacts.len(), 2);
|
||||
assert_ne!(artifacts[0].id, artifacts[1].id);
|
||||
for (artifact, dir) in artifacts.iter().zip(["a", "b"]) {
|
||||
let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap();
|
||||
assert_eq!(plan.cwd, dir);
|
||||
}
|
||||
assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a");
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maven_war_modules_use_resolved_artifact_file() {
|
||||
|
||||
@@ -22,4 +22,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
@@ -252,6 +252,8 @@ pub struct SmartKeysSettings {
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub smart_indent: bool,
|
||||
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
|
||||
pub insert_doc_stub: bool,
|
||||
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
|
||||
pub backspace_unindent: String,
|
||||
}
|
||||
@@ -267,6 +269,7 @@ impl Default for SmartKeysSettings {
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
smart_indent: true,
|
||||
insert_doc_stub: true,
|
||||
backspace_unindent: "autoindent".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,9 @@ pub enum DebugEvent {
|
||||
thread_id: i64,
|
||||
started: bool,
|
||||
},
|
||||
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
|
||||
/// in the debug console reaches its input.
|
||||
ProgramInput,
|
||||
BreakpointsChanged {
|
||||
path: crate::IpcPath,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
//! treats as nonexistent — hidden from the project view and navigation, never
|
||||
//! indexed or searched, never offered as unversioned changes.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Whether `dir` lies in a Git (or jj) working tree: it or an ancestor holds `.git` / `.jj`.
|
||||
///
|
||||
/// Walkers pass it to `ignore::WalkBuilder::require_git`: inside a repository, `.gitignore`
|
||||
/// rules then stop at a nested repository's root as in git, so an outer repository's
|
||||
/// `.gitignore` does not hide a project that is its own repository. A folder outside any
|
||||
/// repository still honours its `.gitignore`.
|
||||
pub fn inside_git_repo(dir: &Path) -> bool {
|
||||
dir.ancestors().any(|d| d.join(".git").exists() || d.join(".jj").exists())
|
||||
}
|
||||
|
||||
/// IDEA's default list, plus `.jj` and the Windows shell files.
|
||||
pub const DEFAULT_IGNORED_NAMES: &[&str] = &[
|
||||
"*.pyc",
|
||||
@@ -103,4 +115,15 @@ mod tests {
|
||||
assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc"));
|
||||
assert!(!m.is_ignored_path("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_repository_at_or_above_a_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("a/b");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
assert!(!inside_git_repo(&sub));
|
||||
std::fs::create_dir(dir.path().join("a/.git")).unwrap();
|
||||
assert!(inside_git_repo(&sub));
|
||||
assert!(inside_git_repo(&dir.path().join("a")));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -111,4 +128,20 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub mod text;
|
||||
pub mod tool_path;
|
||||
pub mod version_managers;
|
||||
pub mod workflow_settings;
|
||||
pub mod wsl;
|
||||
|
||||
pub use error::{IdeError, IdeResult};
|
||||
pub use event::{EventSink, IdeEvent, NullSink, SharedSink};
|
||||
|
||||
+122
-21
@@ -43,19 +43,21 @@ pub fn own_group(cmd: &mut Command) -> &mut Command {
|
||||
/// Kills the child and everything it started, then reaps it.
|
||||
///
|
||||
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
|
||||
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
|
||||
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id, then a
|
||||
/// wait for what the child started to be gone.
|
||||
pub fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
signal_group(child, libc::SIGKILL);
|
||||
#[cfg(windows)]
|
||||
taskkill_tree(child);
|
||||
taskkill_tree_of(child.id());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// [`kill_tree`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
|
||||
/// process group (spawn it with [`own_group`]) or `taskkill /T /F`. Call it before the child
|
||||
/// is reaped, so the id still names it. The child itself is left to its owner to kill and reap.
|
||||
/// process group (spawn it with [`own_group`]) or `taskkill /T /F` and a wait for what the
|
||||
/// child started to be gone. Call it before the child is reaped, so the id still names it.
|
||||
/// The child itself is left to its owner to kill and reap.
|
||||
pub fn kill_tree_of(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = libc::pid_t::try_from(pid) {
|
||||
@@ -65,15 +67,7 @@ pub fn kill_tree_of(pid: u32) {
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
}
|
||||
taskkill_tree_of(pid);
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -106,15 +100,107 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long killing a tree waits for the processes the child started to be gone.
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree(child: &Child) {
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// `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);
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
windows_tree::wait_all(&started, TREE_EXIT_WAIT);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_tree {
|
||||
use std::collections::HashSet;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
use windows_sys::Win32::Foundation::{FILETIME, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// 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> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
let mut parents = vec![(root, root_started)];
|
||||
let mut found = Vec::new();
|
||||
while let Some((parent, parent_started)) = parents.pop() {
|
||||
for &(pid, ppid) in &processes {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
pub(super) fn wait_all(processes: &[OwnedHandle], within: Duration) {
|
||||
let until = Instant::now() + within;
|
||||
for process in processes {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
// SAFETY: a live process handle opened with SYNCHRONIZE access.
|
||||
unsafe { WaitForSingleObject(process.as_raw_handle(), left.as_millis().min(u32::MAX as u128 - 1) as u32) };
|
||||
}
|
||||
}
|
||||
|
||||
/// (process id, parent process id) of every running process.
|
||||
fn processes() -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
|
||||
return out;
|
||||
}
|
||||
let snapshot = OwnedHandle::from_raw_handle(snapshot);
|
||||
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
|
||||
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
|
||||
while more != 0 {
|
||||
out.push((entry.th32ProcessID, entry.th32ParentProcessID));
|
||||
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn open(pid: 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, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
/// When the process started, in 100 ns ticks since 1601.
|
||||
fn started(process: &OwnedHandle) -> Option<u64> {
|
||||
let (mut created, mut exited, mut kernel, mut user) =
|
||||
(FILETIME::default(), FILETIME::default(), FILETIME::default(), FILETIME::default());
|
||||
// SAFETY: a live process handle opened with query access; four out-parameters.
|
||||
let ok = unsafe { GetProcessTimes(process.as_raw_handle(), &mut created, &mut exited, &mut kernel, &mut user) };
|
||||
(ok != 0).then(|| (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
@@ -123,12 +209,14 @@ fn taskkill_tree(child: &Child) {
|
||||
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
|
||||
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
|
||||
/// Variables the call site has already set are kept. A no-op outside an AppImage
|
||||
/// without an overlay.
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]),
|
||||
/// and the login shell's variables this process lacks ([`shell_env`]: an app started from
|
||||
/// Finder/Dock doesn't have what rc files export, such as `GOPATH`, `JAVA_HOME` or
|
||||
/// `MAVEN_OPTS`). Variables the call site has already set are kept.
|
||||
pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
let fixes = child_env_changes();
|
||||
if fixes.is_empty() {
|
||||
let shell: Vec<_> = shell_env().collect();
|
||||
if fixes.is_empty() && shell.is_empty() {
|
||||
return cmd;
|
||||
}
|
||||
let explicit: Vec<OsString> = cmd.get_envs().map(|(k, _)| k.to_owned()).collect();
|
||||
@@ -138,6 +226,9 @@ pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
None => cmd.env_remove(key),
|
||||
};
|
||||
}
|
||||
for (key, value) in shell.into_iter().filter(|(k, _)| !explicit.contains(k)) {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -154,12 +245,13 @@ pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
|
||||
apply_changes(std::env::vars_os().collect(), &child_env_changes()).into_iter()
|
||||
}
|
||||
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]), the login shell's value
|
||||
/// included when this process lacks the variable.
|
||||
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
|
||||
let key = key.as_ref();
|
||||
match child_env_changes().into_iter().find(|(k, _)| k == key) {
|
||||
Some((_, fixed)) => fixed,
|
||||
None => std::env::var_os(key),
|
||||
None => std::env::var_os(key).or_else(|| shell_env().find(|(k, _)| k == key).map(|(_, v)| v)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +664,15 @@ mod tests {
|
||||
assert_eq!(get("HOME"), Some(None));
|
||||
let shell: Vec<_> = shell_env().map(|(k, _)| k).collect();
|
||||
assert_eq!(shell, [OsString::from("OPENIDEA_OVERLAY_B")]);
|
||||
// Every child gets what the login shell exports and the IDE process lacks (a Finder
|
||||
// launch: `GOPATH`, `JAVA_HOME`...), unless the call site sets it.
|
||||
assert_eq!(get("OPENIDEA_OVERLAY_B"), Some(Some("b".into())));
|
||||
let mut own = Command::new("true");
|
||||
own.env("OPENIDEA_OVERLAY_B", "call site");
|
||||
host_env(&mut own);
|
||||
let value = own.get_envs().find(|(n, _)| *n == "OPENIDEA_OVERLAY_B").and_then(|(_, v)| v);
|
||||
assert_eq!(value, Some(OsStr::new("call site")));
|
||||
assert_eq!(host_var_os("OPENIDEA_OVERLAY_B"), Some("b".into()));
|
||||
assert_eq!(host_var_os("PATH"), Some("/reloaded".into()));
|
||||
assert!(host_vars().all(|(k, _)| k != "HOME"));
|
||||
assert!(host_vars().any(|(k, v)| k == "OPENIDEA_OVERLAY_A" && v == "project"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
|
||||
//! targets. The fields hang off `Settings` in `settings.rs`.
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and run
|
||||
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -140,22 +140,55 @@ pub struct DeploymentMapping {
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
|
||||
/// Where a run target runs configurations.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RunTargetKind {
|
||||
/// Another machine over SSH, the project synchronised to its folder.
|
||||
#[default]
|
||||
Ssh,
|
||||
/// A new container of an image (`docker run --rm`), the project bind-mounted.
|
||||
Docker,
|
||||
/// A new container of a Compose service (`docker compose run --rm`).
|
||||
Compose,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
|
||||
/// or in a container.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunTarget {
|
||||
pub kind: RunTargetKind,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
/// [`SshConfiguration::id`] (SSH).
|
||||
pub ssh_config: String,
|
||||
/// Remote folder the project is synchronised to and run from.
|
||||
/// Remote folder the project is synchronised to and run from (SSH).
|
||||
pub project_root: String,
|
||||
/// Upload changed project files before each run.
|
||||
/// Upload changed project files before each run (SSH).
|
||||
pub sync_before_run: bool,
|
||||
/// Interpreters on the target; empty = the one on the remote `PATH`.
|
||||
pub python: String,
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
/// Id of a Docker connection of the Services tool window (Docker, Compose);
|
||||
/// empty = the CLI's own daemon.
|
||||
pub docker_connection: String,
|
||||
/// Image to run (Docker).
|
||||
pub image: String,
|
||||
/// Compose files, relative to the project; empty = the project's default one (Compose).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Service to run (Compose).
|
||||
pub service: String,
|
||||
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
|
||||
/// the service's bind mount of the project (Compose).
|
||||
pub container_root: String,
|
||||
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
|
||||
pub ports: Vec<String>,
|
||||
/// More options for `docker run` / `docker compose run`, shell-like quoted.
|
||||
pub run_options: String,
|
||||
/// Run as the image's user instead of the project folder's owner (Linux).
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -176,4 +209,24 @@ mod deployment_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_target_kind_defaults_to_ssh_and_round_trips() {
|
||||
let legacy: RunTarget =
|
||||
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
|
||||
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
|
||||
let docker = RunTarget {
|
||||
kind: RunTargetKind::Compose,
|
||||
docker_connection: "local".into(),
|
||||
compose_files: vec!["compose.dev.yaml".into()],
|
||||
service: "web".into(),
|
||||
ports: vec!["8000:8000".into()],
|
||||
image_user: true,
|
||||
..legacy
|
||||
};
|
||||
let json = serde_json::to_value(&docker).unwrap();
|
||||
assert_eq!(json["kind"], "compose");
|
||||
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
|
||||
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ pub struct Settings {
|
||||
pub cmake: CMakeSettings,
|
||||
/// Tools › Agents › Chat.
|
||||
pub agents: AgentSettings,
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
/// What the meters in an agent task's header show.
|
||||
@@ -154,6 +157,16 @@ pub struct LanguageServerSettings {
|
||||
pub languages: Vec<String>,
|
||||
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
|
||||
pub root_markers: Vec<String>,
|
||||
/// Replaces the definition's `features`: what the server is used for
|
||||
/// (`code-action`, `hover`, …); an empty list means everything.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub features: Option<Vec<String>>,
|
||||
/// Replaces the definition's `code-action-kinds` (`refactor`); an empty
|
||||
/// list keeps every kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub code_action_kinds: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for LanguageServerSettings {
|
||||
@@ -167,6 +180,8 @@ impl Default for LanguageServerSettings {
|
||||
settings: None,
|
||||
languages: Vec::new(),
|
||||
root_markers: Vec::new(),
|
||||
features: None,
|
||||
code_action_kinds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +230,7 @@ impl Default for Settings {
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,7 +339,8 @@ impl Default for NotificationGroupSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AppearanceSettings {
|
||||
/// `dark` | `light` | `system` | `dark-hc` (high-contrast dark)
|
||||
/// A built-in theme id (`light`, `dark`, `dark-hc`, …), `system` (legacy
|
||||
/// "Sync with OS"), or a plugin's theme: `plugin:<plugin-id>/<theme-id>`.
|
||||
pub theme: String,
|
||||
pub accent: String,
|
||||
pub ui_font_size: f32,
|
||||
@@ -332,6 +349,9 @@ pub struct AppearanceSettings {
|
||||
pub sync_with_os: bool,
|
||||
pub preferred_light_theme: String,
|
||||
pub preferred_dark_theme: String,
|
||||
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
|
||||
pub file_icon_theme: String,
|
||||
pub file_icon_theme_options: BTreeMap<String, FileIconThemeOptions>,
|
||||
/// IDE zoom in percent (50–300).
|
||||
pub zoom: u32,
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
@@ -367,6 +387,24 @@ pub struct AppearanceSettings {
|
||||
/// Main toolbar entries per side, in order: title widget ids (`project`,
|
||||
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
|
||||
/// (registered later) keep their default place.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FileIconThemeOptions {
|
||||
pub groups: BTreeMap<String, bool>,
|
||||
pub choices: BTreeMap<String, String>,
|
||||
pub hide_files: bool,
|
||||
pub hide_folders: bool,
|
||||
pub hollow_folders: bool,
|
||||
pub hide_ui_icons: bool,
|
||||
pub hide_psi_icons: bool,
|
||||
pub monochrome: Option<String>,
|
||||
pub saturation: Option<u8>,
|
||||
pub opacity: Option<u8>,
|
||||
pub icon_size: Option<u8>,
|
||||
pub row_height: Option<u8>,
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MainToolbarSettings {
|
||||
@@ -386,6 +424,8 @@ impl Default for AppearanceSettings {
|
||||
sync_with_os: false,
|
||||
preferred_light_theme: "light".into(),
|
||||
preferred_dark_theme: "dark".into(),
|
||||
file_icon_theme: String::new(),
|
||||
file_icon_theme_options: BTreeMap::new(),
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
@@ -561,7 +601,7 @@ impl Default for FileSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
pub python: PythonSettings,
|
||||
@@ -572,11 +612,34 @@ pub struct LanguagesSettings {
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub prettier: ExternalFormatterSettings,
|
||||
pub oxfmt: ExternalFormatterSettings,
|
||||
pub ruff: ExternalFormatterSettings,
|
||||
pub java: JavaSettings,
|
||||
pub ruby: RubySettings,
|
||||
pub markdown: MarkdownSettings,
|
||||
}
|
||||
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
rust: RustSettings::default(),
|
||||
go: GoSettings::default(),
|
||||
typescript: TypeScriptSettings::default(),
|
||||
prettier: ExternalFormatterSettings::default(),
|
||||
oxfmt: ExternalFormatterSettings::default(),
|
||||
ruff: ExternalFormatterSettings {
|
||||
languages: vec!["python".into()],
|
||||
..ExternalFormatterSettings::default()
|
||||
},
|
||||
java: JavaSettings::default(),
|
||||
ruby: RubySettings::default(),
|
||||
markdown: MarkdownSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -590,6 +653,7 @@ impl LanguagesSettings {
|
||||
match tool {
|
||||
ExternalFormatter::Prettier => &self.prettier,
|
||||
ExternalFormatter::Oxfmt => &self.oxfmt,
|
||||
ExternalFormatter::Ruff => &self.ruff,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +664,7 @@ impl LanguagesSettings {
|
||||
pub enum ExternalFormatter {
|
||||
Prettier,
|
||||
Oxfmt,
|
||||
Ruff,
|
||||
}
|
||||
|
||||
impl ExternalFormatter {
|
||||
@@ -608,16 +673,19 @@ impl ExternalFormatter {
|
||||
match self {
|
||||
Self::Prettier => "prettier",
|
||||
Self::Oxfmt => "oxfmt",
|
||||
Self::Ruff => "ruff",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An external formatter (Prettier, oxfmt) and the languages it formats.
|
||||
/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExternalFormatterSettings {
|
||||
/// Empty path: project node_modules, managed installation, then PATH.
|
||||
/// Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH.
|
||||
pub path: String,
|
||||
/// Ruff only: directory containing its configuration, or a configuration file.
|
||||
pub configuration_directory: String,
|
||||
pub languages: Vec<String>,
|
||||
/// Prefer this formatter even when a language server offers formatting.
|
||||
pub prefer: bool,
|
||||
@@ -628,6 +696,7 @@ impl Default for ExternalFormatterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
configuration_directory: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
@@ -673,6 +742,19 @@ impl Default for PythonSettings {
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: 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,
|
||||
/// Folders outside the project whose code the language server indexes too (a platform a
|
||||
/// module extends); absolute, `~/`, or relative to the project.
|
||||
pub include_paths: Vec<String>,
|
||||
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
|
||||
/// `composer.json` (`wordpress`).
|
||||
pub stubs: Vec<String>,
|
||||
/// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
|
||||
/// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
|
||||
/// `~/`, or relative to the project.
|
||||
pub phpstan_config: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -779,6 +861,8 @@ pub struct JavaSettings {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GradleSettings {
|
||||
/// Linked build directories; absent preserves automatic discovery, empty unlinks all builds.
|
||||
pub linked_projects: Option<Vec<String>>,
|
||||
/// Empty = Project JDK; otherwise a JDK home.
|
||||
pub jvm: String,
|
||||
pub distribution: GradleDistribution,
|
||||
@@ -815,12 +899,20 @@ pub enum GradleDistribution {
|
||||
pub struct MavenSettings {
|
||||
/// 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.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: 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
|
||||
/// its own Maven, so `home` and the project's `mvnw` are bypassed when it is on.
|
||||
pub daemon: bool,
|
||||
/// Maven Daemon home (or its `mvnd` executable); empty = `$MVND_HOME`, else
|
||||
/// `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on.
|
||||
pub mvnd_home: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -911,6 +1003,36 @@ mod tests {
|
||||
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_icon_theme_options_default_empty_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"fileIconTheme":"p/t"}}"#).unwrap();
|
||||
assert!(old.appearance.file_icon_theme_options.is_empty());
|
||||
let json = serde_json::json!({"appearance":{"fileIconThemeOptions":{"p/t":{
|
||||
"groups":{"tests":true},"choices":{"arrows":"plusminus"},"hideFiles":true,"hideFolders":false,"hollowFolders":true,"hideUiIcons":true,"hidePsiIcons":false,
|
||||
"monochrome":"#546e7a","saturation":5,"opacity":80,"iconSize":20,"rowHeight":30,"accent":"#009688"}}}});
|
||||
let settings: Settings = serde_json::from_value(json.clone()).unwrap();
|
||||
let options = &settings.appearance.file_icon_theme_options["p/t"];
|
||||
assert!(options.hide_files && options.hollow_folders && !options.hide_folders);
|
||||
assert!(options.hide_ui_icons && !options.hide_psi_icons);
|
||||
assert!(options.groups["tests"]);
|
||||
assert_eq!(options.choices["arrows"], "plusminus");
|
||||
assert_eq!(
|
||||
(options.saturation, options.opacity, options.icon_size, options.row_height),
|
||||
(Some(5), Some(80), Some(20), Some(30))
|
||||
);
|
||||
assert_eq!(options.monochrome.as_deref(), Some("#546e7a"));
|
||||
assert_eq!(options.accent.as_deref(), Some("#009688"));
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
let restored: Settings = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored, settings);
|
||||
let plain = serde_json::to_value(FileIconThemeOptions::default()).unwrap();
|
||||
assert_eq!(plain["hideFiles"], false);
|
||||
assert_eq!(plain["hideUiIcons"], false);
|
||||
assert_eq!(plain["hidePsiIcons"], false);
|
||||
assert_eq!(plain["monochrome"], serde_json::Value::Null);
|
||||
assert_eq!(plain["groups"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
@@ -1022,12 +1144,33 @@ mod maven_linkage_tests {
|
||||
assert!(encoded.contains("unlinkedProjects"));
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_settings_round_trip() {
|
||||
// Old settings files predate the daemon fields; `serde(default)` keeps them loadable.
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(!old.daemon && old.mvnd_home.is_empty());
|
||||
let settings = MavenSettings { daemon: true, mvnd_home: "/opt/mvnd".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome"));
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
|
||||
let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
|
||||
let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
|
||||
assert_eq!(automatic.linked_projects, None);
|
||||
assert_eq!(unlinked.linked_projects, Some(vec![]));
|
||||
let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
|
||||
assert_eq!(restored.linked_projects, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_round_trip() {
|
||||
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
|
||||
@@ -1040,6 +1183,7 @@ mod gradle_settings_tests {
|
||||
home: "/gradle".into(),
|
||||
user_home: "/cache/gradle".into(),
|
||||
run_using: GradleRunUsing::Ide,
|
||||
linked_projects: Some(vec!["backend".into(), "../shared".into()]),
|
||||
};
|
||||
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle);
|
||||
|
||||
@@ -30,6 +30,21 @@ pub fn search_path() -> OsString {
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which
|
||||
/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one.
|
||||
pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')";
|
||||
|
||||
/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist.
|
||||
pub fn gem_bin_dirs(output: &str) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for dir in output.lines().map(str::trim).map(PathBuf::from) {
|
||||
if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Resolves a command on the shared path, or an explicit path relative to `root`.
|
||||
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
let path = Path::new(command);
|
||||
@@ -128,7 +143,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
|
||||
fn version_key(name: &str) -> Vec<u64> {
|
||||
pub fn version_key(name: &str) -> Vec<u64> {
|
||||
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Projects that live inside a WSL distro are reached from Windows through the
|
||||
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) share.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The WSL launcher on Windows.
|
||||
pub const EXE: &str = "wsl.exe";
|
||||
|
||||
/// A location inside a WSL distro.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WslPath {
|
||||
pub distro: String,
|
||||
/// Absolute Linux path, e.g. `/home/me/app`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Splits a WSL share path into distro and Linux path. Pure string work, so it
|
||||
/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`,
|
||||
/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`.
|
||||
pub fn parse(path: &Path) -> Option<WslPath> {
|
||||
let s = path.to_string_lossy().replace('\\', "/");
|
||||
let s = match s.strip_prefix("//?/UNC/") {
|
||||
Some(rest) => format!("//{rest}"),
|
||||
None => s,
|
||||
};
|
||||
let rest = s.strip_prefix("//")?;
|
||||
let (host, rest) = rest.split_once('/')?;
|
||||
if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") {
|
||||
return None;
|
||||
}
|
||||
let (distro, tail) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
if distro.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) })
|
||||
}
|
||||
|
||||
/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits,
|
||||
/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too.
|
||||
pub fn is_valid_distro(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 64
|
||||
&& !name.starts_with('-')
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||
}
|
||||
|
||||
/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands
|
||||
/// and its error messages, UTF-8 for what a command inside the distro prints.
|
||||
pub fn decode_output(raw: &[u8]) -> String {
|
||||
if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) {
|
||||
let units: Vec<u16> = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
|
||||
String::from_utf16_lossy(&units)
|
||||
} else {
|
||||
String::from_utf8_lossy(raw).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when
|
||||
/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also
|
||||
/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none.
|
||||
pub fn parse_distro_list(raw: &[u8]) -> Vec<String> {
|
||||
let text = decode_output(raw);
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
|
||||
if is_valid_distro(name) && !names.iter().any(|n| n == name) {
|
||||
names.push(name.to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails.
|
||||
pub fn list_distros() -> Vec<String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new(EXE);
|
||||
cmd.args(["-l", "-q"]);
|
||||
crate::process::prepare(&mut cmd);
|
||||
match cmd.output() {
|
||||
Ok(out) if out.status.success() => parse_distro_list(&out.stdout),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> Option<(String, String)> {
|
||||
parse(Path::new(s)).map(|w| (w.distro, w.path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_spelling_of_the_share_is_recognised() {
|
||||
let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned()));
|
||||
assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want);
|
||||
assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want);
|
||||
assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want);
|
||||
assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_root_and_foreign_paths() {
|
||||
assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned())));
|
||||
assert_eq!(p(r"\\server\share\a"), None);
|
||||
assert_eq!(p(r"\\wsl.localhost\"), None);
|
||||
assert_eq!(p("C:/Users/me"), None);
|
||||
assert_eq!(p("/home/me"), None);
|
||||
}
|
||||
|
||||
fn utf16(s: &str) -> Vec<u8> {
|
||||
std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_list_is_decoded_from_utf16_and_utf8() {
|
||||
let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()];
|
||||
assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want);
|
||||
assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want);
|
||||
assert!(
|
||||
parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty()
|
||||
);
|
||||
assert!(parse_distro_list(b"").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_names_cannot_smuggle_options_or_paths() {
|
||||
for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] {
|
||||
assert!(!is_valid_distro(bad), "{bad}");
|
||||
}
|
||||
assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop"));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! "Import a report collected in CI from disk": the format is sniffed from the
|
||||
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
|
||||
//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another
|
||||
//! machine are mapped into the project by their longest existing suffix.
|
||||
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) enum Format {
|
||||
Lcov,
|
||||
GoProfile,
|
||||
Jacoco,
|
||||
SimpleCov,
|
||||
}
|
||||
|
||||
pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
@@ -18,6 +19,9 @@ pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
if head.starts_with('<') {
|
||||
return head.contains("<report").then_some(Format::Jacoco);
|
||||
}
|
||||
if head.starts_with('{') {
|
||||
return head.contains("\"coverage\"").then_some(Format::SimpleCov);
|
||||
}
|
||||
let first = head.lines().next().unwrap_or_default();
|
||||
if first.starts_with("mode:") {
|
||||
return Some(Format::GoProfile);
|
||||
@@ -30,6 +34,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut suite = match detect(&text) {
|
||||
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
|
||||
Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"),
|
||||
Some(Format::GoProfile) => {
|
||||
let (module, module_dir) = go_module_for(root, &text);
|
||||
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
|
||||
@@ -50,7 +55,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
|
||||
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
@@ -125,9 +130,29 @@ mod tests {
|
||||
Some(Format::Jacoco)
|
||||
);
|
||||
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
|
||||
assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov));
|
||||
assert_eq!(detect("{\"name\": \"package.json\"}"), None);
|
||||
assert_eq!(detect("hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_simplecov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
std::fs::create_dir_all(root.join("lib")).unwrap();
|
||||
std::fs::write(root.join("lib/a.rb"), "").unwrap();
|
||||
let report = tmp.path().join(".resultset.json");
|
||||
std::fs::write(
|
||||
&report,
|
||||
r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.runner, "SimpleCov");
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb"));
|
||||
assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_lcov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov |
|
||||
//! | Node.js scripts | `c8` | lcov |
|
||||
//! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov |
|
||||
//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset |
|
||||
//! | Go (`go test`) | `-coverprofile` | Go cover profile |
|
||||
//! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML |
|
||||
//!
|
||||
@@ -20,6 +21,7 @@ mod import;
|
||||
mod jacoco;
|
||||
mod lcov;
|
||||
mod plan;
|
||||
mod simplecov;
|
||||
|
||||
pub use import::import;
|
||||
pub use plan::prepare;
|
||||
@@ -83,6 +85,8 @@ pub enum CoverageReportSpec {
|
||||
Lcov { runner: String, path: IpcPath, base: IpcPath },
|
||||
/// `go test -coverprofile`; import paths under `module` map to `module_dir`.
|
||||
GoProfile { path: IpcPath, module: String, module_dir: IpcPath },
|
||||
/// SimpleCov's `.resultset.json`; relative paths resolve against `base`.
|
||||
SimpleCov { path: IpcPath, base: IpcPath },
|
||||
/// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`.
|
||||
Jacoco { dir: IpcPath },
|
||||
/// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o <lcov>`).
|
||||
@@ -185,6 +189,10 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
let text = read_report(path)?;
|
||||
Ok(lcov::parse(&text, base).finish(runner))
|
||||
}
|
||||
CoverageReportSpec::SimpleCov { path, base } => {
|
||||
let text = read_report(path)?;
|
||||
Ok(simplecov::parse(&text, base)?.finish("SimpleCov"))
|
||||
}
|
||||
CoverageReportSpec::GoProfile { path, module, module_dir } => {
|
||||
let text = read_report(path)?;
|
||||
Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover"))
|
||||
|
||||
@@ -67,6 +67,7 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared
|
||||
"gradle" | "gradlew" => gradle(config, &dir, out),
|
||||
"npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out),
|
||||
"node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out),
|
||||
"ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out),
|
||||
"deno" => unsupported("Coverage of Deno tasks is not supported yet."),
|
||||
"java" => unsupported(
|
||||
"Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.",
|
||||
@@ -234,6 +235,65 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar
|
||||
ready("c8", config, path(&c8), args, report)
|
||||
}
|
||||
|
||||
/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself.
|
||||
const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage.
|
||||
begin
|
||||
require "simplecov"
|
||||
rescue LoadError
|
||||
# `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on
|
||||
# the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov.
|
||||
return
|
||||
end
|
||||
SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR"))
|
||||
SimpleCov.start do
|
||||
add_filter %r{^/(test|spec|features)/}
|
||||
enable_coverage :branch
|
||||
end
|
||||
"#;
|
||||
|
||||
/// Files where a project conventionally starts SimpleCov.
|
||||
const SIMPLECOV_STARTERS: &[&str] =
|
||||
&[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"];
|
||||
|
||||
fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
|
||||
let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file());
|
||||
let mentions = |file: &str| {
|
||||
[dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov")))
|
||||
};
|
||||
if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") {
|
||||
return missing(
|
||||
"simplecov",
|
||||
"Ruby coverage needs the simplecov gem in the project's Gemfile.",
|
||||
Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)),
|
||||
);
|
||||
}
|
||||
let starts = |d: &Path| {
|
||||
SIMPLECOV_STARTERS.iter().any(|f| {
|
||||
std::fs::read_to_string(d.join(f))
|
||||
.is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov"))
|
||||
})
|
||||
};
|
||||
let mut config = config.clone();
|
||||
let resultset = if [dir, root].iter().any(|d| starts(d)) {
|
||||
// The project's own configuration decides where the report goes (default `coverage/`).
|
||||
// Drop the previous run's data: SimpleCov merges into it.
|
||||
let results = dir.join("coverage");
|
||||
let _ = std::fs::remove_file(results.join(".resultset.json"));
|
||||
results.join(".resultset.json")
|
||||
} else {
|
||||
let preload = out.join("openidea_simplecov.rb");
|
||||
if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) {
|
||||
return unsupported(format!("Cannot write the SimpleCov preload: {e}"));
|
||||
}
|
||||
config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out));
|
||||
// `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them).
|
||||
ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true);
|
||||
out.join(".resultset.json")
|
||||
};
|
||||
let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() };
|
||||
ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report)
|
||||
}
|
||||
|
||||
fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared {
|
||||
if py.target.is_empty() {
|
||||
return unsupported("Coverage needs a Python script or module to run.");
|
||||
@@ -398,6 +458,74 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov
|
||||
/// on the load path it must step aside instead of failing the launcher.
|
||||
#[test]
|
||||
fn simplecov_preload_tolerates_a_ruby_without_the_gem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("out dir");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap();
|
||||
let status = std::process::Command::new("ruby")
|
||||
.args(["-e", "exit 0"])
|
||||
.env("RUBYLIB", &out)
|
||||
.env("RUBYOPT", "-ropenidea_simplecov")
|
||||
.env("OPENIDEA_COVERAGE_DIR", &out)
|
||||
.env("GEM_HOME", dir.path().join("empty gems"))
|
||||
.env("GEM_PATH", dir.path().join("empty gems"))
|
||||
.status();
|
||||
// No Ruby on this machine: nothing to check.
|
||||
let Ok(status) = status else { return };
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_runs_get_simplecov() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
// The IDE's data directory has spaces on macOS (`Application Support`).
|
||||
let out = root.join("out dir");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
|
||||
let config = cfg("rspec", "bundle", &["exec", "rspec"]);
|
||||
// Bundler project without the gem: install hint.
|
||||
match prepare(root, &config, &out) {
|
||||
CoveragePrepared::MissingTool { tool, install: Some(i), .. } => {
|
||||
assert_eq!(tool, "simplecov");
|
||||
assert_eq!(i.args, ["add", "simplecov", "--group", "test"]);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
// Gem present, never started: preloaded through RUBYOPT, report in `out`.
|
||||
std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap();
|
||||
let p = prepare(root, &config, &out);
|
||||
let (l, report) = launch(&p);
|
||||
assert_eq!(
|
||||
(l.command.as_str(), l.args.as_slice()),
|
||||
("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice())
|
||||
);
|
||||
assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov"));
|
||||
assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out));
|
||||
assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy());
|
||||
assert!(out.join("openidea_simplecov.rb").is_file());
|
||||
assert!(
|
||||
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out))
|
||||
);
|
||||
// Started by the project: untouched environment, stale data dropped, report in `coverage/`.
|
||||
std::fs::create_dir_all(root.join("test")).unwrap();
|
||||
std::fs::create_dir_all(root.join("coverage")).unwrap();
|
||||
std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap();
|
||||
std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap();
|
||||
let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out);
|
||||
let (l, report) = launch(&p);
|
||||
assert!(!l.env.contains_key("RUBYOPT"));
|
||||
assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]);
|
||||
assert!(!root.join("coverage/.resultset.json").exists());
|
||||
assert!(
|
||||
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_commands_are_unsupported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! SimpleCov's `coverage/.resultset.json`:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "RSpec": { "timestamp": 1, "coverage": {
|
||||
//! "/abs/lib/a.rb": { "lines": [null, 1, 0],
|
||||
//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } },
|
||||
//! "/abs/lib/legacy.rb": [null, 1] } } }
|
||||
//! ```
|
||||
//!
|
||||
//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based.
|
||||
//! A branch belongs to the line its condition starts on (third number of its key).
|
||||
|
||||
use crate::Accumulator;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key.
|
||||
fn start_line(key: &str) -> Option<u32> {
|
||||
key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn parse(text: &str, base: &Path) -> IdeResult<Accumulator> {
|
||||
let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?;
|
||||
let mut acc = Accumulator::default();
|
||||
// One entry per test command ("RSpec", "Minitest", …); their coverage is merged.
|
||||
for suite in json.as_object().into_iter().flat_map(|o| o.values()) {
|
||||
let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue };
|
||||
for (name, data) in files {
|
||||
let p = Path::new(name);
|
||||
let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
|
||||
acc.file(&path);
|
||||
let lines = data.get("lines").or(Some(data)).and_then(Value::as_array);
|
||||
for (i, hits) in lines.into_iter().flatten().enumerate() {
|
||||
if let Some(hits) = hits.as_u64() {
|
||||
acc.line(&path, i as u32, hits);
|
||||
}
|
||||
}
|
||||
for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() {
|
||||
let Some(line) = start_line(cond) else { continue };
|
||||
for (branch, hits) in branches.as_object().into_iter().flatten() {
|
||||
acc.has_branches = true;
|
||||
acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_lines_branches_and_merges_suites() {
|
||||
let text = r#"{
|
||||
"RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0],
|
||||
"branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}},
|
||||
"Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}}
|
||||
}"#;
|
||||
let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov");
|
||||
assert_eq!(suite.files.len(), 2);
|
||||
let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap();
|
||||
assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::<Vec<_>>(), [(1, 3), (2, 3)]);
|
||||
assert_eq!((a.branches_total, a.branches_covered), (2, 1));
|
||||
assert_eq!(a.lines[0].branches_total, 2);
|
||||
let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap();
|
||||
assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb"));
|
||||
assert_eq!(old.lines[0].hits, 1);
|
||||
assert!(suite.has_branches);
|
||||
assert!(parse("nope", Path::new("/p")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ specta.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
parking_lot.workspace = true
|
||||
memchr = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use ide_core::process::executable_names;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -44,6 +44,8 @@ pub struct AdapterSpec {
|
||||
/// `host:port` of an adapter inside another program (the Godot editor): connected to
|
||||
/// when it accepts, else `command` is started.
|
||||
pub connect: Option<String>,
|
||||
/// A plugin language's adapter: start exactly this approved program.
|
||||
pub pinned: Option<ProgramPin>,
|
||||
}
|
||||
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
@@ -55,27 +57,34 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
transport: transport.to_owned(),
|
||||
connect: None,
|
||||
install_hint: Some(hint.to_owned()),
|
||||
pinned: None,
|
||||
};
|
||||
Some(match adapter {
|
||||
"codelldb" => cfg(
|
||||
"codelldb",
|
||||
&["--port", PORT_PLACEHOLDER],
|
||||
"tcp",
|
||||
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
|
||||
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"kotlin-debug-adapter" => cfg(
|
||||
"kotlin-debug-adapter",
|
||||
&[],
|
||||
"stdio",
|
||||
"Install the Kotlin Debug Adapter from Settings > Language Servers",
|
||||
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
|
||||
"debugpy" => cfg(
|
||||
if cfg!(windows) { "python" } else { "python3" },
|
||||
&["-m", "debugpy.adapter"],
|
||||
"stdio",
|
||||
"pip install debugpy",
|
||||
),
|
||||
"debugpy" => cfg("python3", &["-m", "debugpy.adapter"], "stdio", "pip install debugpy"),
|
||||
"js-debug" => cfg(
|
||||
"js-debug-adapter",
|
||||
// Without a host it listens on `localhost`, which may resolve to ::1 only.
|
||||
&[PORT_PLACEHOLDER, "127.0.0.1"],
|
||||
"tcp",
|
||||
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
|
||||
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"delve" => cfg(
|
||||
"dlv",
|
||||
@@ -87,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"netcoredbg",
|
||||
&["--interpreter=vscode"],
|
||||
"stdio",
|
||||
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
|
||||
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
|
||||
),
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
@@ -133,6 +142,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
|
||||
env: Vec::new(),
|
||||
server_host: None,
|
||||
connect: config.connect.clone(),
|
||||
pinned: config.pinned.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,12 +436,20 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
if is_plugin_bundle(&spec.command) {
|
||||
return Err(not_standalone(&spec));
|
||||
}
|
||||
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
|
||||
let binary = tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?;
|
||||
let binary = match spec.pinned.clone() {
|
||||
Some(pin) => tokio::task::spawn_blocking(move || pin.verify().map(Path::to_path_buf))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?,
|
||||
None => {
|
||||
let (command, cwd) = (spec.command.clone(), spec.cwd.clone());
|
||||
tokio::task::spawn_blocking(move || resolve_binary(&command, &cwd))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
@@ -650,7 +668,7 @@ mod tests {
|
||||
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
|
||||
let err = missing(&spec);
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("netcoredbg/releases"));
|
||||
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+125
-6
@@ -212,6 +212,19 @@ pub struct Variable {
|
||||
pub evaluate_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A completion the adapter offers for an expression (DAP `completions`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DebugCompletion {
|
||||
pub label: String,
|
||||
/// What to insert, when it differs from `label`.
|
||||
pub text: Option<String>,
|
||||
/// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, …
|
||||
pub kind: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
pub sort_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StepKind {
|
||||
@@ -289,6 +302,23 @@ pub struct DebugManager {
|
||||
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
|
||||
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
|
||||
/// `cwd`; the program's own directory is tried first because a module inside a
|
||||
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
|
||||
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
|
||||
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
|
||||
start.push(dir);
|
||||
start
|
||||
.iter()
|
||||
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
|
||||
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
|
||||
.any(|s| s.contains("sdk: flutter"))
|
||||
}
|
||||
|
||||
fn no_session(s: DebugSessionId) -> IdeError {
|
||||
IdeError::not_found(format!("no debug session {}", s.0))
|
||||
}
|
||||
@@ -557,6 +587,10 @@ impl DebugManager {
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let mut arguments = launch_arguments(&config)?;
|
||||
// An IDE-side marker (never sent to the adapter): the session is a Flutter
|
||||
// one regardless of what the working directory holds.
|
||||
let force_flutter =
|
||||
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let cwd = config
|
||||
.cwd
|
||||
.clone()
|
||||
@@ -576,17 +610,27 @@ impl DebugManager {
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
|
||||
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
|
||||
if config.adapter == "debugpy" {
|
||||
let python = match arguments.get("python") {
|
||||
Some(Value::String(p)) => Some(p.clone()),
|
||||
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
|
||||
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
|
||||
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(python) = python {
|
||||
spec.command = python;
|
||||
}
|
||||
}
|
||||
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
|
||||
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
|
||||
// is Flutter-only). Plain Dart projects keep the configured `dart`.
|
||||
if config.adapter == "dart" && spec.command == "dart" {
|
||||
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
|
||||
if force_flutter || is_flutter_project(&cwd, program) {
|
||||
spec.command = "flutter".into();
|
||||
}
|
||||
}
|
||||
let id = DebugSessionId::next();
|
||||
let debug_server = take_debug_server(&mut arguments)?;
|
||||
if let Some(server) = &debug_server {
|
||||
@@ -638,8 +682,19 @@ impl DebugManager {
|
||||
{
|
||||
arguments["project"] = json!(real);
|
||||
}
|
||||
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
if config.adapter == "codelldb" {
|
||||
if arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
}
|
||||
// The Debug Console and Evaluate Expression take expressions, as with every other
|
||||
// debugger; LLDB commands still run with a leading backtick.
|
||||
match arguments.get_mut("_adapterSettings") {
|
||||
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
|
||||
Some(Value::Object(settings)) => {
|
||||
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let port = debug_server.map(|s| s.port);
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
|
||||
@@ -1157,6 +1212,47 @@ impl DebugManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Completions for `text` at `line` / `column` (0-based, UTF-16) in the frame's scope; none
|
||||
/// when the adapter doesn't offer them.
|
||||
pub async fn completions(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<DebugCompletion>> {
|
||||
let session = self.session(s)?;
|
||||
if !session.supports("supportsCompletionsRequest") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut args = json!({ "text": text, "line": line + 1, "column": column + 1 });
|
||||
if let Some(f) = frame_id {
|
||||
args["frameId"] = f.into();
|
||||
}
|
||||
let body = session.conn.request_timeout("completions", args, REQUEST_TIMEOUT).await?;
|
||||
let text =
|
||||
|v: &Value, key: &str| v.get(key).and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned);
|
||||
Ok(body
|
||||
.get("targets")
|
||||
.and_then(Value::as_array)
|
||||
.map(|targets| {
|
||||
targets
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
Some(DebugCompletion {
|
||||
label: text(t, "label")?,
|
||||
text: text(t, "text"),
|
||||
kind: text(t, "type"),
|
||||
detail: text(t, "detail"),
|
||||
sort_text: text(t, "sortText"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
@@ -1170,6 +1266,29 @@ impl DebugManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
|
||||
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let mut program = session.program.lock();
|
||||
let program = program
|
||||
.as_mut()
|
||||
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
|
||||
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: injects the changed sources into the running app.
|
||||
/// Not part of the DAP standard; adapters that do not know the request reject it.
|
||||
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flutter's DAP adapter: restarts the app without preserving state.
|
||||
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
|
||||
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
|
||||
let command = match kind {
|
||||
StepKind::Over => "next",
|
||||
|
||||
@@ -43,6 +43,8 @@ pub(crate) struct Session {
|
||||
pub adapter_process: Mutex<Option<Child>>,
|
||||
/// Processes started for `runInTerminal`.
|
||||
pub debuggees: Mutex<Vec<Child>>,
|
||||
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
|
||||
pub program: Mutex<Option<ProgramTerminal>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
@@ -90,6 +92,7 @@ impl Session {
|
||||
caps: Mutex::new(Value::Null),
|
||||
adapter_process: Mutex::new(child),
|
||||
debuggees: Mutex::new(Vec::new()),
|
||||
program: Mutex::new(None),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
@@ -111,8 +114,8 @@ impl Session {
|
||||
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
|
||||
return path;
|
||||
}
|
||||
if self.adapter == "java-debug" {
|
||||
let native = java_source_path(&path.to_string_lossy());
|
||||
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
let native = windows_source_path(&path.to_string_lossy());
|
||||
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
|
||||
return native;
|
||||
}
|
||||
@@ -133,7 +136,11 @@ impl Session {
|
||||
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
|
||||
return Some(real);
|
||||
}
|
||||
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
|
||||
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
|
||||
windows_source_path(path)
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
|
||||
(self.adapter == "java-debug" && is_windows_source_path(path))
|
||||
.then(|| PathBuf::from(path.replace('\\', "/")))
|
||||
@@ -262,11 +269,14 @@ impl Session {
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
if let Some(mut program) = self.program.lock().take() {
|
||||
let _ = program.child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// java-debug treats a forward-slash drive path as a URI (scheme "C").
|
||||
// Keep DAP path mode, but use native Windows separators for Java source lookup.
|
||||
// java-debug and Windows netcoredbg need native separators for source lookup.
|
||||
// Keep DAP path mode, but normalize Windows drive and UNC paths.
|
||||
fn is_windows_source_path(path: &str) -> bool {
|
||||
let bytes = path.as_bytes();
|
||||
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
|
||||
@@ -274,7 +284,7 @@ fn is_windows_source_path(path: &str) -> bool {
|
||||
|| path.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn java_source_path(path: &str) -> PathBuf {
|
||||
fn windows_source_path(path: &str) -> PathBuf {
|
||||
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
|
||||
}
|
||||
|
||||
@@ -447,8 +457,25 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
}
|
||||
}
|
||||
|
||||
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
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_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
|
||||
/// starts a launcher that gives the program its terminal, and the program reads input only from
|
||||
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
|
||||
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
let argv: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
@@ -457,16 +484,25 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let Some((program, rest)) = argv.split_first() else {
|
||||
return Err("runInTerminal without args".into());
|
||||
};
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(rest)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
cmd.current_dir(cwd);
|
||||
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}"))?;
|
||||
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"));
|
||||
for (k, v) in ide_core::process::host_env_fixes() {
|
||||
match v {
|
||||
Some(v) => cmd.env(k, v),
|
||||
None => cmd.env_remove(k),
|
||||
}
|
||||
}
|
||||
for (k, v) in ide_core::process::shell_env() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
|
||||
Some(cwd) => cmd.cwd(cwd),
|
||||
None => cmd.cwd(&s.spec.cwd),
|
||||
}
|
||||
if let Some(env) = args.get("env").and_then(Value::as_object) {
|
||||
for (k, v) in env {
|
||||
@@ -476,18 +512,53 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let pid = child.id();
|
||||
if let Some(out) = child.stdout.take() {
|
||||
forward_output(s, out, "stdout");
|
||||
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 pid = child.process_id();
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
forward_output(s, err, "stderr");
|
||||
}
|
||||
s.debuggees.lock().push(child);
|
||||
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
|
||||
Ok(json!({ "processId": pid }))
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
}
|
||||
// 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) {
|
||||
Ok(_) => pending.len(),
|
||||
Err(e) if e.error_len().is_none() => e.valid_up_to(),
|
||||
Err(_) => pending.len(),
|
||||
};
|
||||
if valid > 0 && pending[valid - 1] == b'\r' {
|
||||
valid -= 1;
|
||||
}
|
||||
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
|
||||
pending.drain(..valid);
|
||||
if !output.is_empty() {
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
|
||||
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": true,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
@@ -172,6 +173,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
]})
|
||||
),
|
||||
"evaluate" => ok!(msg, json!({"result": "42", "type": "int", "variablesReference": 0})),
|
||||
"completions" => ok!(
|
||||
msg,
|
||||
json!({"targets": [
|
||||
{"label": "getName()", "text": "getName", "type": "method", "detail": "String"},
|
||||
{"label": "name", "type": "field"},
|
||||
{"label": ""},
|
||||
]})
|
||||
),
|
||||
"continue" => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
|
||||
@@ -368,6 +377,14 @@ async fn full_protocol_flow() {
|
||||
let eval = h.request("evaluate");
|
||||
assert_eq!(eval["arguments"]["context"], "watch");
|
||||
assert_eq!(eval["arguments"]["frameId"], 10);
|
||||
let items = h.manager.completions(id, Some(10), "req.na".into(), 0, 6).await.unwrap();
|
||||
assert_eq!(
|
||||
items.iter().map(|c| (c.label.as_str(), c.text.as_deref(), c.kind.as_deref())).collect::<Vec<_>>(),
|
||||
[("getName()", Some("getName"), Some("method")), ("name", None, Some("field"))]
|
||||
);
|
||||
let asked = h.request("completions");
|
||||
assert_eq!((asked["arguments"]["line"].clone(), asked["arguments"]["column"].clone()), (json!(1), json!(7)));
|
||||
assert_eq!(asked["arguments"]["frameId"], 10);
|
||||
|
||||
// Live breakpoint updates reach the session.
|
||||
let states = h.manager.set_breakpoints(path.clone(), vec![bp(0)]).await.unwrap();
|
||||
@@ -451,6 +468,40 @@ async fn run_in_terminal_spawns_debuggee() {
|
||||
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn run_in_terminal_program_reads_typed_input() {
|
||||
let h = harness(Script {
|
||||
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
|
||||
run_in_terminal: Some(vec![
|
||||
"sh".into(),
|
||||
"-c".into(),
|
||||
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
|
||||
"--clear-screen".into(),
|
||||
]),
|
||||
..Script::default()
|
||||
});
|
||||
let session = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
|
||||
h.manager.send_input(session, "Bob\n").unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
|
||||
let output: String = h
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
|
||||
Some(output.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!output.contains('\r'), "{output:?}");
|
||||
h.manager.stop(session).await.unwrap();
|
||||
assert!(h.manager.send_input(session, "x\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
@@ -681,6 +732,37 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
|
||||
assert_eq!(launch["type"], "pwa-node");
|
||||
}
|
||||
|
||||
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
|
||||
/// and the Debug Console send expressions, so its console evaluates them. A launch
|
||||
/// configuration that picks a console mode keeps it.
|
||||
#[tokio::test]
|
||||
async fn codelldb_console_evaluates_expressions() {
|
||||
let launch_with = |extra: Value| async move {
|
||||
let h = harness(Script::default());
|
||||
let config = LaunchConfig {
|
||||
name: "demo".into(),
|
||||
adapter: "codelldb".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/p/target/debug/demo".into()),
|
||||
args: vec![],
|
||||
cwd: Some("/p".into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
};
|
||||
h.start(config).await.unwrap();
|
||||
h.request("launch")["arguments"].clone()
|
||||
};
|
||||
let launch = launch_with(json!({})).await;
|
||||
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
|
||||
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
|
||||
|
||||
let launch =
|
||||
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
|
||||
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
|
||||
}
|
||||
|
||||
/// An adapter that is already listening (java-debug inside jdtls): the client
|
||||
/// connects over TCP to the `debugServer` port instead of spawning anything.
|
||||
#[tokio::test]
|
||||
@@ -906,6 +988,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
@@ -915,6 +998,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
transport: "tcp".into(),
|
||||
connect: Some(format!("127.0.0.1:{port}")),
|
||||
install_hint: Some("Open the project in the Godot editor".into()),
|
||||
pinned: None,
|
||||
}),
|
||||
};
|
||||
Arc::new(LanguageRegistry::from_configs(vec![lang]))
|
||||
@@ -1091,6 +1175,23 @@ async fn kotlin_official_release_ten_no_delay_runs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn netcoredbg_windows_source_paths_round_trip() {
|
||||
let client = "D:/work/LoginController.cs";
|
||||
let native = r"D:\work\LoginController.cs";
|
||||
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
|
||||
let path = PathBuf::from(client);
|
||||
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
|
||||
let mut cfg = config();
|
||||
cfg.adapter = "netcoredbg".into();
|
||||
let id = h.start(cfg).await.unwrap();
|
||||
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
|
||||
let frames = h.manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_windows_source_paths_for_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
@@ -1147,3 +1248,74 @@ async fn java_stack_frames_without_breakpoints_normalize_windows_paths() {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A built-in language definition (GDScript) shipped by a plugin
|
||||
/// instead: its debugger starts the program approved for the plugin's
|
||||
/// `process:godot` grant, and refuses once that file changed.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
use ide_language::ProgramPin;
|
||||
use ide_language::plugin_point::{PluginLanguages, parse_definition};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("openidea-plugin-dap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Stands in for `godot --headless --editor …`.
|
||||
let program = dir.join("godot");
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let mut gdscript = parse_definition(include_str!("../../../languages/gdscript.toml")).unwrap();
|
||||
let debugger = gdscript.debugger.as_mut().unwrap();
|
||||
debugger.args.clear();
|
||||
debugger.connect = Some(format!("127.0.0.1:{port}"));
|
||||
|
||||
// No built-in GDScript: it comes from the plugin.
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(Vec::new()));
|
||||
let store = PluginLanguages::new();
|
||||
registry.attach_plugins(store.clone());
|
||||
let pin = ProgramPin::approve(&program).unwrap();
|
||||
store.set_process_policy(Arc::new(move |plugin, cap| {
|
||||
(plugin == "acme.godot" && cap.to_string() == "process:godot").then(|| pin.clone())
|
||||
}));
|
||||
let _language = store.add("acme.godot".into(), gdscript);
|
||||
|
||||
let log: Log = Arc::default();
|
||||
let server_log = log.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
fake_adapter(stream, server_log, Script::default()).await;
|
||||
});
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let manager = DebugManager::new(registry.clone());
|
||||
let id = manager.start(godot_launch(&dir), owner).await.unwrap();
|
||||
assert!(log.lock().iter().any(|m| m["command"] == "launch"));
|
||||
manager.stop(id).await.unwrap();
|
||||
|
||||
// Replaced after approval: refused before anything starts.
|
||||
std::fs::write(&program, "#!/bin/sh\nexec sleep 31\n").unwrap();
|
||||
let (owner, _events) = recording_owner("main");
|
||||
let err = manager.start(godot_launch(&dir), owner).await.unwrap_err();
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
|
||||
let app = root.join("app");
|
||||
let tool = root.join("tool");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::create_dir_all(tool.join("bin")).unwrap();
|
||||
std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
|
||||
std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
|
||||
assert!(is_flutter_project(&app, None));
|
||||
assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
|
||||
assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
|
||||
assert!(!is_flutter_project(&root, None));
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
|
||||
//! configuration does: a launch without a program makes it listen, and every PHP
|
||||
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
|
||||
//! file, through `pathMappings`, until the session is stopped. A second listener on
|
||||
//! the same port fails to start and names the port.
|
||||
//!
|
||||
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
|
||||
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
|
||||
//! (`PHP_BIN` picks the PHP executable, default `php`).
|
||||
|
||||
use ide_core::event::DebugEvent;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_dap::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
|
||||
/// 0-based line of `$n = 40 + 2;`.
|
||||
const BREAK_LINE: u32 = 2;
|
||||
|
||||
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(e) = events.lock().iter().find_map(|e| match e {
|
||||
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return e;
|
||||
}
|
||||
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
|
||||
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
|
||||
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
|
||||
LaunchConfig {
|
||||
name: "PHP Remote".into(),
|
||||
adapter: "php-debug".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(local.to_path_buf().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
|
||||
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
|
||||
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
|
||||
return;
|
||||
};
|
||||
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The project here, and the copy PHP runs from (a container's /var/www/html).
|
||||
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
|
||||
for d in [&local, &served] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
|
||||
}
|
||||
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
|
||||
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
|
||||
let (local, served) = (resolved(&local), resolved(&served));
|
||||
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let local_script = local.join("index.php");
|
||||
manager
|
||||
.set_breakpoints(
|
||||
local_script.clone(),
|
||||
vec![Breakpoint {
|
||||
line: BREAK_LINE,
|
||||
condition: None,
|
||||
hit_condition: None,
|
||||
log_message: None,
|
||||
enabled: true,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let id = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
|
||||
Some(adapter.clone())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
// Requests in a row each stop; the session keeps listening between them.
|
||||
for request in 1..=2 {
|
||||
events.lock().clear();
|
||||
let run = std::process::Command::new(&php)
|
||||
.args([
|
||||
"-dxdebug.mode=debug",
|
||||
"-dxdebug.start_with_request=yes",
|
||||
"-dxdebug.client_host=127.0.0.1",
|
||||
&format!("-dxdebug.client_port={port}"),
|
||||
"index.php",
|
||||
])
|
||||
.current_dir(&served)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
|
||||
let frames = manager.stack_trace(id, thread_id).await.unwrap();
|
||||
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
|
||||
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
|
||||
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
|
||||
manager.resume(id, thread_id).await.unwrap();
|
||||
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
|
||||
let out = run.wait_with_output().unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
|
||||
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
|
||||
}
|
||||
|
||||
// Another listener on the port (another IDE): the start fails and says why.
|
||||
let err = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
|
||||
.await
|
||||
.expect_err("a second listener on the same port");
|
||||
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
|
||||
assert_eq!(manager.sessions(None).len(), 1);
|
||||
|
||||
manager.stop(id).await.unwrap();
|
||||
assert!(manager.sessions(None).is_empty());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -17,11 +17,17 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracle.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-http.workspace = true
|
||||
reqwest = { workspace = true, features = ["query"] }
|
||||
rustls.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
@@ -0,0 +1,889 @@
|
||||
use crate::conn::{QueryResult, ResultColumn, Setup};
|
||||
use crate::source::{DataSource, DbSslMode, DbTxMode};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::{Client, RequestBuilder, StatusCode, Url};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClickhousePool(Arc<State>);
|
||||
|
||||
struct State {
|
||||
client: RwLock<Option<Client>>,
|
||||
endpoint: Url,
|
||||
user: String,
|
||||
password: String,
|
||||
database: String,
|
||||
settings: Vec<(String, String)>,
|
||||
read_only: bool,
|
||||
session_id: String,
|
||||
session: Mutex<bool>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
/// The response format: a names line, a types line, then one JSON array per row, so rows past
|
||||
/// the grid's limit are skipped without being parsed.
|
||||
const FORMAT: &str = "JSONCompactEachRowWithNamesAndTypes";
|
||||
|
||||
pub(crate) fn transaction_error() -> IdeError {
|
||||
IdeError::invalid("ClickHouse HTTP does not support SQL transactions; use auto-commit and run writes directly")
|
||||
}
|
||||
|
||||
impl ClickhousePool {
|
||||
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: Setup) -> IdeResult<Self> {
|
||||
if source.options.transaction == DbTxMode::Manual {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
for (name, _) in &setup.params {
|
||||
if reserved_setting(name) {
|
||||
return Err(IdeError::invalid(format!("ClickHouse HTTP manages the {name} setting")));
|
||||
}
|
||||
}
|
||||
let secure = matches!(source.ssl, DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull);
|
||||
let tls_files = [&source.ssl_ca_file, &source.ssl_cert_file, &source.ssl_key_file];
|
||||
if !secure && tls_files.iter().any(|p| !p.trim().is_empty()) {
|
||||
return Err(IdeError::invalid(
|
||||
"ClickHouse TLS certificate files require an SSL mode of Require, Verify CA, or Verify Full",
|
||||
));
|
||||
}
|
||||
let host = source.host.trim();
|
||||
if host.is_empty() || host.contains(['/', '@', '?', '#']) {
|
||||
return Err(IdeError::invalid("ClickHouse host must contain only a hostname or IP address"));
|
||||
}
|
||||
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
|
||||
let scheme = if secure { "https" } else { "http" };
|
||||
let port = source.port.unwrap_or(if secure { 8443 } else { 8123 });
|
||||
let endpoint = Url::parse(&format!("{scheme}://{host}:{port}/"))
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse HTTP host or port"))?;
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let mut builder = Client::builder()
|
||||
.connect_timeout(timeout)
|
||||
.pool_max_idle_per_host(4)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("OpenIDEA/", env!("CARGO_PKG_VERSION")));
|
||||
let ca = source.ssl_ca_file.trim();
|
||||
if !ca.is_empty() {
|
||||
let pem = read_pem(ca, "CA certificate")?;
|
||||
let certificates = reqwest::Certificate::from_pem_bundle(&pem)
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse CA certificate PEM"))?;
|
||||
if certificates.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse CA certificate PEM contains no certificates"));
|
||||
}
|
||||
builder = builder.tls_certs_only(certificates);
|
||||
}
|
||||
if source.ssl == DbSslMode::VerifyCa {
|
||||
if ca.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse Verify CA requires a CA certificate file"));
|
||||
}
|
||||
builder = builder.tls_danger_accept_invalid_hostnames(true);
|
||||
}
|
||||
let cert = source.ssl_cert_file.trim();
|
||||
let key = source.ssl_key_file.trim();
|
||||
if cert.is_empty() != key.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse client TLS requires both certificate and key files"));
|
||||
}
|
||||
if !cert.is_empty() {
|
||||
let mut pem = read_pem(cert, "client certificate")?;
|
||||
pem.push(b'\n');
|
||||
pem.extend(read_pem(key, "client key")?);
|
||||
let identity = reqwest::Identity::from_pem(&pem)
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse client certificate or key PEM"))?;
|
||||
builder = builder.identity(identity);
|
||||
}
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let client = ide_http::client::with_proxy(builder, &proxy).build().map_err(http_error)?;
|
||||
let database = if source.options.default_schema.trim().is_empty() {
|
||||
source.database.trim()
|
||||
} else {
|
||||
source.options.default_schema.trim()
|
||||
};
|
||||
let pool = Self(Arc::new(State {
|
||||
client: RwLock::new(Some(client)),
|
||||
endpoint,
|
||||
user: if source.user.is_empty() { "default".into() } else { source.user.clone() },
|
||||
password: password.unwrap_or_default().to_owned(),
|
||||
database: if database.is_empty() { "default".into() } else { database.into() },
|
||||
settings: setup.params,
|
||||
read_only: source.read_only,
|
||||
session_id: uuid::Uuid::new_v4().to_string(),
|
||||
session: Mutex::new(false),
|
||||
closed: CancellationToken::new(),
|
||||
}));
|
||||
let cancel = CancellationToken::new();
|
||||
let initialize = async {
|
||||
pool.execute("SELECT 1", 1, &cancel).await?;
|
||||
for sql in setup.statements {
|
||||
pool.execute(&sql, 0, &cancel).await?;
|
||||
}
|
||||
Ok(pool.clone())
|
||||
};
|
||||
tokio::pin!(initialize);
|
||||
tokio::select! {
|
||||
result = &mut initialize => {
|
||||
if result.is_err() {
|
||||
pool.close();
|
||||
}
|
||||
result
|
||||
},
|
||||
() = tokio::time::sleep(timeout) => {
|
||||
cancel.cancel();
|
||||
let _ = initialize.await;
|
||||
pool.close();
|
||||
Err(IdeError::new(ErrorKind::TimedOut, "timed out connecting to ClickHouse HTTP"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.0.closed.cancel();
|
||||
self.0.client.write().take();
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.0.closed.is_cancelled()
|
||||
}
|
||||
|
||||
fn client(&self) -> IdeResult<Client> {
|
||||
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
|
||||
}
|
||||
|
||||
/// `max_rows`: the rows the server may send (the skipped ones included).
|
||||
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
|
||||
let mut request = client
|
||||
.post(self.0.endpoint.clone())
|
||||
.basic_auth(&self.0.user, Some(&self.0.password))
|
||||
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.query(&self.0.settings)
|
||||
.query(&[
|
||||
("session_id", self.0.session_id.clone()),
|
||||
("session_timeout", "3600".into()),
|
||||
("query_id", query_id.to_owned()),
|
||||
("default_format", FORMAT.into()),
|
||||
("output_format_json_quote_denormals", "1".into()),
|
||||
("max_result_rows", max_rows.saturating_add(1).to_string()),
|
||||
("result_overflow_mode", "break".into()),
|
||||
]);
|
||||
if initialized {
|
||||
request = request.query(&[("session_check", "1")]);
|
||||
} else {
|
||||
request = request.query(&[("database", &self.0.database)]);
|
||||
}
|
||||
if self.0.read_only {
|
||||
request = request.query(&[("readonly", "1")]);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
self.execute_from(sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without parsing or keeping them.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if transaction_statement(sql) {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
let mut session = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.0.closed.cancelled() => return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed")),
|
||||
session = self.0.session.lock() => session,
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
|
||||
let started = Instant::now();
|
||||
let fetch = async {
|
||||
let mut response = request.send().await.map_err(http_error)?;
|
||||
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let status = response.status();
|
||||
let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let exception_code = header("X-ClickHouse-Exception-Code");
|
||||
let format = header("X-ClickHouse-Format");
|
||||
let affected = header("X-ClickHouse-Summary")
|
||||
.and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())
|
||||
.and_then(|v| {
|
||||
v.get("written_rows")
|
||||
.and_then(|n| n.as_str().and_then(|n| n.parse::<u64>().ok()).or_else(|| n.as_u64()))
|
||||
});
|
||||
if !status.is_success() || exception_code.as_deref().is_some_and(|code| code != "0") {
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
return Err(self.server_error(status, exception_code.as_deref(), &body));
|
||||
}
|
||||
let (columns, rows, has_more) = if format.as_deref().is_some_and(|f| f != FORMAT) {
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
if !body.iter().all(u8::is_ascii_whitespace) {
|
||||
return Err(IdeError::invalid(
|
||||
"ClickHouse result grids need the default output format; remove the query's FORMAT clause",
|
||||
));
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
reader.finish().map_err(|error| self.redact_error(error))?
|
||||
};
|
||||
let affected = if columns.is_empty() { affected.map(|n| n as f64) } else { None };
|
||||
Ok(QueryResult {
|
||||
statement: sql.to_owned(),
|
||||
columns,
|
||||
rows,
|
||||
affected,
|
||||
has_more,
|
||||
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
execution_ms,
|
||||
})
|
||||
};
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => None,
|
||||
() = self.0.closed.cancelled() => None,
|
||||
result = fetch => Some(result),
|
||||
};
|
||||
match result {
|
||||
Some(result) => {
|
||||
if result.is_ok() {
|
||||
*session = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
None => {
|
||||
let killed = self.kill(&client, &query_id).await;
|
||||
match killed {
|
||||
Ok(()) => Err(IdeError::cancelled()),
|
||||
Err(error) => Err(IdeError::new(
|
||||
ErrorKind::Cancelled,
|
||||
format!("cancelled locally; ClickHouse server cancellation failed: {}", error.message),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn kill(&self, client: &Client, query_id: &str) -> IdeResult<()> {
|
||||
let response = client
|
||||
.post(self.0.endpoint.clone())
|
||||
.basic_auth(&self.0.user, Some(&self.0.password))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.body(format!("KILL QUERY WHERE query_id = '{query_id}' SYNC"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_error)?;
|
||||
let status = response.status();
|
||||
let code = response
|
||||
.headers()
|
||||
.get("X-ClickHouse-Exception-Code")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
if status.is_success() && !code.as_deref().is_some_and(|code| code != "0") && !body.starts_with(b"Code:") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(self.server_error(status, code.as_deref(), &body))
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_error(&self, mut error: IdeError) -> IdeError {
|
||||
if !self.0.password.is_empty() {
|
||||
error.message = error.message.replace(&self.0.password, "[redacted]");
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
fn server_error(&self, status: StatusCode, code: Option<&str>, body: &[u8]) -> IdeError {
|
||||
let message = String::from_utf8_lossy(body);
|
||||
let message: String = exception_text(&message).chars().take(16_384).collect();
|
||||
let auth = matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|
||||
|| matches!(code, Some("193" | "194" | "195" | "516" | "497"));
|
||||
let detail = if message.is_empty() { status.canonical_reason().unwrap_or("request failed") } else { &message };
|
||||
let detail = if detail.contains("SESSION_NOT_FOUND") || detail.contains("Session not found") {
|
||||
"ClickHouse HTTP session expired; reconnect to restore session settings"
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
self.redact_error(IdeError::new(
|
||||
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
|
||||
format!("ClickHouse HTTP {}: {detail}", status.as_u16()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
|
||||
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
|
||||
/// unparsed, so a grid limit bounds memory however large the block
|
||||
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// starts the exception ClickHouse writes when a query fails mid-stream.
|
||||
struct RowReader {
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
pending: Vec<u8>,
|
||||
names: Option<Vec<String>>,
|
||||
types: Option<Vec<String>>,
|
||||
rows: Vec<Vec<Option<String>>>,
|
||||
more: bool,
|
||||
exception: Option<String>,
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
fn new(skip: usize, max_rows: usize) -> Self {
|
||||
Self {
|
||||
skip,
|
||||
max_rows,
|
||||
pending: Vec::new(),
|
||||
names: None,
|
||||
types: None,
|
||||
rows: Vec::new(),
|
||||
more: false,
|
||||
exception: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
|
||||
self.pending.extend_from_slice(chunk);
|
||||
let buffer = std::mem::take(&mut self.pending);
|
||||
let mut start = 0;
|
||||
while let Some(end) = buffer[start..].iter().position(|&b| b == b'\n') {
|
||||
self.line(&buffer[start..start + end])?;
|
||||
start += end + 1;
|
||||
}
|
||||
self.pending = buffer[start..].to_vec();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &[u8]) -> IdeResult<()> {
|
||||
if let Some(exception) = &mut self.exception {
|
||||
if exception.len() < 16_384 {
|
||||
exception.push_str(&String::from_utf8_lossy(line));
|
||||
exception.push('\n');
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let text = line.trim_ascii();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if text[0] != b'[' {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return Ok(());
|
||||
}
|
||||
if self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
};
|
||||
let protocol = |message: String| IdeError::new(ErrorKind::Protocol, message);
|
||||
let header = |values: Vec<Box<serde_json::value::RawValue>>| {
|
||||
values
|
||||
.iter()
|
||||
.map(|v| serde_json::from_str::<String>(v.get()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| protocol(format!("invalid ClickHouse result header: {e}")))
|
||||
};
|
||||
let Some(names) = &self.names else {
|
||||
self.names = Some(header(values)?);
|
||||
return Ok(());
|
||||
};
|
||||
if self.types.is_none() {
|
||||
self.types = Some(header(values)?);
|
||||
return Ok(());
|
||||
}
|
||||
if values.len() != names.len() {
|
||||
return Err(protocol("ClickHouse result row width does not match its metadata".into()));
|
||||
}
|
||||
let row = values
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
let text = value.get();
|
||||
if text == "null" {
|
||||
Ok(None)
|
||||
} else if text.starts_with('"') {
|
||||
serde_json::from_str::<String>(text).map(Some).map_err(|e| protocol(e.to_string()))
|
||||
} else {
|
||||
Ok(Some(text.to_owned()))
|
||||
}
|
||||
})
|
||||
.collect::<IdeResult<_>>()?;
|
||||
self.rows.push(row);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> IdeResult<DecodedResult> {
|
||||
let rest = std::mem::take(&mut self.pending);
|
||||
self.line(&rest)?;
|
||||
if let Some(exception) = self.exception {
|
||||
return Err(IdeError::invalid(format!("ClickHouse: {}", exception_text(&exception))));
|
||||
}
|
||||
let Some(names) = self.names else { return Ok((Vec::new(), Vec::new(), false)) };
|
||||
let types = self.types.unwrap_or_default();
|
||||
if types.len() != names.len() {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "ClickHouse result types do not match its columns"));
|
||||
}
|
||||
let columns = names.into_iter().zip(types).map(|(name, type_name)| ResultColumn { name, type_name }).collect();
|
||||
Ok((columns, self.rows, self.more))
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's message in a failed response: rows already streamed and the
|
||||
/// `__exception__` block markers ClickHouse wraps a mid-stream error in are dropped.
|
||||
fn exception_text(body: &str) -> &str {
|
||||
let Some(at) = body.find("Code:") else { return body.trim() };
|
||||
let text = &body[at..];
|
||||
match text.find("\n__exception__") {
|
||||
// The line before the closing marker is `<length> <tag>`.
|
||||
Some(end) => text[..end].trim_end().rsplit_once('\n').map_or(text[..end].trim(), |(message, _)| message.trim()),
|
||||
None => text.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_error(error: reqwest::Error) -> IdeError {
|
||||
use std::error::Error;
|
||||
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
|
||||
let error = error.without_url();
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(error) = source {
|
||||
let detail = error.to_string();
|
||||
if !message.contains(&detail) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&detail);
|
||||
}
|
||||
source = error.source();
|
||||
}
|
||||
IdeError::new(kind, format!("ClickHouse HTTP: {message}"))
|
||||
}
|
||||
|
||||
fn read_pem(path: &str, label: &str) -> IdeResult<Vec<u8>> {
|
||||
std::fs::read(ide_ssh::expand(path))
|
||||
.map_err(|error| IdeError::new(ErrorKind::Io, format!("cannot read ClickHouse {label}: {error}")))
|
||||
}
|
||||
|
||||
fn reserved_setting(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.starts_with("output_format_json_")
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"user"
|
||||
| "password"
|
||||
| "query"
|
||||
| "database"
|
||||
| "session_id"
|
||||
| "session_check"
|
||||
| "session_timeout"
|
||||
| "query_id"
|
||||
| "default_format"
|
||||
| "max_result_rows"
|
||||
| "result_overflow_mode"
|
||||
| "readonly"
|
||||
)
|
||||
}
|
||||
|
||||
fn transaction_statement(sql: &str) -> bool {
|
||||
let mut sql = sql.trim_start();
|
||||
loop {
|
||||
if let Some(rest) = sql.strip_prefix("--").or_else(|| sql.strip_prefix('#')) {
|
||||
sql = rest.split_once('\n').map_or("", |(_, rest)| rest).trim_start();
|
||||
} else if let Some(rest) = sql.strip_prefix("/*") {
|
||||
sql = rest.split_once("*/").map_or("", |(_, rest)| rest).trim_start();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut words = sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|s| !s.is_empty());
|
||||
let first = words.next().unwrap_or_default();
|
||||
["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"].iter().any(|s| first.eq_ignore_ascii_case(s))
|
||||
|| (first.eq_ignore_ascii_case("START") && words.next().is_some_and(|s| s.eq_ignore_ascii_case("TRANSACTION")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::conn::Pool;
|
||||
use crate::source::{DbDriver, DbOptions, DbProperty};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const ONE: &str = "[\"1\"]\n[\"UInt8\"]\n[1]\n";
|
||||
|
||||
async fn read_request(stream: &mut tokio::net::TcpStream) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 4096];
|
||||
loop {
|
||||
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
|
||||
assert!(count > 0, "request ended early");
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|s| s == b"\r\n\r\n") {
|
||||
let header = String::from_utf8_lossy(&bytes[..end]);
|
||||
let length: usize = header
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
return String::from_utf8(bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(stream: &mut tokio::net::TcpStream, status: &str, headers: &str, body: &str) {
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len());
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
async fn server(
|
||||
responses: Vec<(&'static str, &'static str, &'static str)>,
|
||||
) -> (u16, tokio::task::JoinHandle<Vec<String>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut requests = Vec::new();
|
||||
for (status, headers, body) in responses {
|
||||
let (mut stream, _) =
|
||||
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
requests.push(read_request(&mut stream).await);
|
||||
reply(&mut stream, status, headers, body).await;
|
||||
}
|
||||
requests
|
||||
});
|
||||
(port, task)
|
||||
}
|
||||
|
||||
fn source(port: u16) -> DataSource {
|
||||
DataSource { driver: DbDriver::Clickhouse, host: "127.0.0.1".into(), port: Some(port), ..Default::default() }
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
decode_from(body, 0, max_rows, chunk)
|
||||
}
|
||||
|
||||
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
for part in body.chunks(chunk) {
|
||||
reader.push(part)?;
|
||||
}
|
||||
reader.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_preserve_precision_nulls_and_strings_across_chunks() {
|
||||
let body = concat!(
|
||||
r#"["n", "d", "v", "v", "v"]"#,
|
||||
"\n",
|
||||
r#"["UInt64", "Decimal(38, 9)", "Nullable(String)", "Nullable(String)", "Nullable(String)"]"#,
|
||||
"\n",
|
||||
r#"[18446744073709551615, 12345678901234567890.123456789, null, "null", "ᴺᵁᴸᴸ"]"#,
|
||||
);
|
||||
for chunk in [1, 7, body.len()] {
|
||||
let (columns, rows, more) = decode(body.as_bytes(), 1, chunk).unwrap();
|
||||
assert_eq!(columns.len(), 5);
|
||||
assert_eq!(columns[1].type_name, "Decimal(38, 9)");
|
||||
assert_eq!(
|
||||
rows[0],
|
||||
vec![
|
||||
Some("18446744073709551615".into()),
|
||||
Some("12345678901234567890.123456789".into()),
|
||||
None,
|
||||
Some("null".into()),
|
||||
Some("ᴺᵁᴸᴸ".into()),
|
||||
]
|
||||
);
|
||||
assert!(!more);
|
||||
}
|
||||
let (columns, rows, more) = decode(b"[\"x\"]\n[\"String\"]\n", 0, 64).unwrap();
|
||||
assert_eq!(columns[0].name, "x");
|
||||
assert!(rows.is_empty() && !more);
|
||||
assert_eq!(decode(b"", 5, 64).unwrap().0.len(), 0);
|
||||
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_page_skips_rows_without_keeping_them() {
|
||||
let body = b"[\"n\"]\n[\"UInt8\"]\n[1]\n[2]\n[3]\n[4]\n[5]\n";
|
||||
let page = |skip, max| {
|
||||
let (_, rows, more) = decode_from(body, skip, max, 3).unwrap();
|
||||
(rows.into_iter().map(|r| r[0].clone().unwrap()).collect::<Vec<_>>(), more)
|
||||
};
|
||||
assert_eq!(page(2, 2), (vec!["3".to_owned(), "4".to_owned()], true));
|
||||
assert_eq!(page(4, 2), (vec!["5".to_owned()], false));
|
||||
assert_eq!(page(9, 2), (Vec::<String>::new(), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
|
||||
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
|
||||
for n in 0..1000 {
|
||||
body.push_str(&format!("[{n}]\n"));
|
||||
}
|
||||
let (_, rows, more) = decode(body.as_bytes(), 2, 4096).unwrap();
|
||||
assert_eq!(rows, vec![vec![Some("0".into())], vec![Some("1".into())]]);
|
||||
assert!(more);
|
||||
body.push_str("\n__exception__\nabcdefgh\nCode: 395. DB::Exception: Value passed to 'throwIf' is non-zero\n");
|
||||
body.push_str("62 abcdefgh\n__exception__\n");
|
||||
let error = decode(body.as_bytes(), 2, 4096).unwrap_err();
|
||||
assert_eq!(error.message, "ClickHouse: Code: 395. DB::Exception: Value passed to 'throwIf' is non-zero");
|
||||
let multiline = "Code: 62. DB::Exception: Syntax error\nat line 2";
|
||||
assert_eq!(exception_text(multiline), multiline);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_requests_use_auth_settings_limits_and_a_shared_session() {
|
||||
let (port, requests) = server(vec![
|
||||
("200 OK", "X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n", ONE),
|
||||
("200 OK", "", ""),
|
||||
(
|
||||
"200 OK",
|
||||
"X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n",
|
||||
"[\"n\"]\n[\"UInt64\"]\n[\"1\"]\n[\"2\"]\n[\"3\"]\n",
|
||||
),
|
||||
])
|
||||
.await;
|
||||
let source = DataSource {
|
||||
user: "reader".into(),
|
||||
read_only: true,
|
||||
options: DbOptions { default_schema: "analytics".into(), time_zone: "UTC".into(), ..Default::default() },
|
||||
properties: vec![DbProperty { name: "max_threads".into(), value: "2".into() }],
|
||||
..source(port)
|
||||
};
|
||||
let pool = Pool::connect(&source, Some("mock-password"), None).await.unwrap();
|
||||
pool.execute("USE other", 0, &CancellationToken::new()).await.unwrap();
|
||||
let result = pool.execute("SELECT number FROM numbers(3)", 1, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(result.rows, vec![vec![Some("1".into())]]);
|
||||
assert!(result.has_more && result.affected.is_none());
|
||||
let requests = requests.await.unwrap();
|
||||
let target = |request: &String| {
|
||||
Url::parse(&format!(
|
||||
"http://localhost{}",
|
||||
request.lines().next().unwrap().split_whitespace().nth(1).unwrap()
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let urls: Vec<_> = requests.iter().map(target).collect();
|
||||
let value = |url: &Url, key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
|
||||
assert_eq!(value(&urls[0], "database").as_deref(), Some("analytics"));
|
||||
assert_eq!(value(&urls[1], "database"), None);
|
||||
assert_eq!(value(&urls[1], "session_check").as_deref(), Some("1"));
|
||||
assert_eq!(value(&urls[0], "session_id"), value(&urls[2], "session_id"));
|
||||
assert_ne!(value(&urls[0], "query_id"), value(&urls[2], "query_id"));
|
||||
assert_eq!(value(&urls[2], "max_result_rows").as_deref(), Some("2"));
|
||||
for (request, url) in requests.iter().zip(&urls) {
|
||||
assert!(request.starts_with("POST /?"));
|
||||
assert!(request.contains("Basic cmVhZGVyOm1vY2stcGFzc3dvcmQ="));
|
||||
assert!(!url.as_str().contains("reader") && !url.as_str().contains("mock-password"));
|
||||
assert_eq!(value(url, "readonly").as_deref(), Some("1"));
|
||||
assert_eq!(value(url, "session_timezone").as_deref(), Some("UTC"));
|
||||
assert_eq!(value(url, "max_threads").as_deref(), Some("2"));
|
||||
}
|
||||
assert!(requests[0].ends_with("SELECT 1"));
|
||||
assert!(pool.transaction(&[]).await.is_err());
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(pool.execute("SELECT 1", 1, &cancel).await.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
assert!(pool.execute("SELECT 1", 1, &CancellationToken::new()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sequential_queries_reuse_the_http_connection() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
for _ in 0..2 {
|
||||
read_request(&mut stream).await;
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{ONE}", ONE.len());
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
assert_eq!(
|
||||
pool.execute("SELECT 1", 1, &CancellationToken::new()).await.unwrap().rows,
|
||||
vec![vec![Some("1".into())]]
|
||||
);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_errors_keep_details_and_hide_passwords() {
|
||||
let (port, requests) = server(vec![(
|
||||
"403 Forbidden",
|
||||
"X-ClickHouse-Exception-Code: 516\r\n",
|
||||
"Code: 516. DB::Exception: bad mock-password",
|
||||
)])
|
||||
.await;
|
||||
let error = match Pool::connect(&source(port), Some("mock-password"), None).await {
|
||||
Ok(_) => panic!("expected login failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert!(error.message.contains("516") && error.message.contains("[redacted]"));
|
||||
assert!(!error.message.contains("mock-password"));
|
||||
requests.await.unwrap();
|
||||
let (port, requests) = server(vec![("200 OK", "", "Code: 241. DB::Exception: memory limit")]).await;
|
||||
let error = match Pool::connect(&source(port), None, None).await {
|
||||
Ok(_) => panic!("expected late query failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.message.contains("memory limit"));
|
||||
requests.await.unwrap();
|
||||
let (port, requests) = server(vec![(
|
||||
"500 Internal Server Error",
|
||||
"X-ClickHouse-Exception-Code: 395\r\n",
|
||||
"[\"1\"]\n[\"UInt8\"]\n[1]\n\n__exception__\ntag\nCode: 395. DB::Exception: thrown\n32 tag\n__exception__\n",
|
||||
)])
|
||||
.await;
|
||||
let error = match Pool::connect(&source(port), None, None).await {
|
||||
Ok(_) => panic!("expected mid-stream failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.message, "ClickHouse HTTP 500: Code: 395. DB::Exception: thrown");
|
||||
requests.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_sends_kill_outside_the_busy_session() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (started, received) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut health, _) = listener.accept().await.unwrap();
|
||||
read_request(&mut health).await;
|
||||
reply(&mut health, "200 OK", "", ONE).await;
|
||||
drop(health);
|
||||
let (mut query, _) = listener.accept().await.unwrap();
|
||||
let request = read_request(&mut query).await;
|
||||
started.send(()).unwrap();
|
||||
let (mut kill, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
let kill_request = read_request(&mut kill).await;
|
||||
reply(&mut kill, "200 OK", "", "").await;
|
||||
(request, kill_request)
|
||||
});
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let query = pool.execute("SELECT sleep(30)", 1, &cancel);
|
||||
let cancelled = async {
|
||||
received.await.unwrap();
|
||||
cancel.cancel();
|
||||
};
|
||||
let (result, ()) = tokio::join!(query, cancelled);
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
let (query, kill) = server.await.unwrap();
|
||||
let target = query.lines().next().unwrap().split_whitespace().nth(1).unwrap();
|
||||
let url = Url::parse(&format!("http://localhost{target}")).unwrap();
|
||||
let query_id = url.query_pairs().find(|(k, _)| k == "query_id").unwrap().1.into_owned();
|
||||
assert!(kill.ends_with(&format!("KILL QUERY WHERE query_id = '{query_id}' SYNC")));
|
||||
assert!(!kill.contains("session_id="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_settings_and_transactions_fail_before_connecting() {
|
||||
for statement in ["BEGIN", "-- comment\n COMMIT", "/* comment */ ROLLBACK", "START TRANSACTION", "SAVEPOINT s"]
|
||||
{
|
||||
assert!(transaction_statement(statement));
|
||||
}
|
||||
assert!(!transaction_statement("SELECT 'BEGIN'"));
|
||||
let sources = [
|
||||
DataSource { options: DbOptions { transaction: DbTxMode::Manual, ..Default::default() }, ..source(1) },
|
||||
DataSource {
|
||||
properties: vec![DbProperty { name: "statement_cache_capacity".into(), value: "4".into() }],
|
||||
..source(1)
|
||||
},
|
||||
DataSource { properties: vec![DbProperty { name: "password".into(), value: "x".into() }], ..source(1) },
|
||||
DataSource { ssl_ca_file: "unused.pem".into(), ..source(1) },
|
||||
DataSource { ssl: DbSslMode::VerifyCa, ..source(1) },
|
||||
DataSource { ssl: DbSslMode::Require, ssl_cert_file: "unused.pem".into(), ..source(1) },
|
||||
];
|
||||
for source in sources {
|
||||
let error = match Pool::connect(&source, None, None).await {
|
||||
Ok(_) => panic!("unsupported settings were accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
|
||||
async fn clickhouse_cloud_transport() {
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("CLICKHOUSE_TEST_HOST"),
|
||||
port: Some(std::env::var("CLICKHOUSE_TEST_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8443)),
|
||||
user: std::env::var("CLICKHOUSE_TEST_USER").expect("CLICKHOUSE_TEST_USER"),
|
||||
ssl: DbSslMode::Require,
|
||||
options: DbOptions {
|
||||
time_zone: "UTC".into(),
|
||||
startup_script: "SET max_threads = 2".into(),
|
||||
..Default::default()
|
||||
},
|
||||
properties: vec![DbProperty { name: "max_block_size".into(), value: "50".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("CLICKHOUSE_TEST_PASSWORD");
|
||||
let pool = Pool::connect(&source, Some(&password), None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let settings = pool
|
||||
.execute("SELECT getSetting('max_threads'), getSetting('max_block_size'), timezone()", 1, &cancel)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.rows, vec![vec![Some("2".into()), Some("50".into()), Some("UTC".into())]]);
|
||||
let result = pool.execute("SELECT toUInt64('18446744073709551615') AS n, CAST(NULL AS Nullable(String)) AS missing, CAST('ᴺᵁᴸᴸ' AS Nullable(String)) AS text, 'a\\nb' AS lines", 1, &cancel).await.unwrap();
|
||||
assert_eq!(
|
||||
result.rows,
|
||||
vec![vec![Some("18446744073709551615".into()), None, Some("ᴺᵁᴸᴸ".into()), Some("a\nb".into())]]
|
||||
);
|
||||
assert_eq!(result.columns[1].type_name, "Nullable(String)");
|
||||
let limited = pool.execute("SELECT number FROM numbers(100)", 2, &cancel).await.unwrap();
|
||||
assert_eq!(limited.rows.len(), 2);
|
||||
assert!(limited.has_more);
|
||||
let marker = format!("transport_cancel_{}", uuid::Uuid::new_v4().simple());
|
||||
let sql = format!("SELECT sleepEachRow(0.05) FROM numbers(50) /* {marker} */");
|
||||
let stop = CancellationToken::new();
|
||||
let query = pool.execute(&sql, 1, &stop);
|
||||
let cancel_query = async {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
stop.cancel();
|
||||
};
|
||||
let (cancelled, ()) = tokio::join!(query, cancel_query);
|
||||
let cancelled = cancelled.unwrap_err();
|
||||
assert_eq!(cancelled.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(cancelled.message, "cancelled");
|
||||
let active = pool.execute(&format!("SELECT count() FROM system.processes WHERE position(query, '{marker}') > 0 AND query_id != currentQueryID()"), 1, &cancel).await.unwrap();
|
||||
assert_eq!(active.rows, vec![vec![Some("0".into())]]);
|
||||
let table = format!("transport_test_{}", uuid::Uuid::new_v4().simple());
|
||||
pool.execute(&format!("CREATE TEMPORARY TABLE {table} (n UInt8)"), 0, &cancel).await.unwrap();
|
||||
let continuity = async {
|
||||
pool.execute(&format!("INSERT INTO {table} VALUES (7)"), 0, &cancel).await?;
|
||||
pool.execute(&format!("SELECT n FROM {table}"), 1, &cancel).await
|
||||
}
|
||||
.await;
|
||||
let cleanup = pool.execute(&format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
|
||||
assert_eq!(continuity.unwrap().rows, vec![vec![Some("7".into())]]);
|
||||
cleanup.unwrap();
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
}
|
||||
}
|
||||
+143
-14
@@ -106,6 +106,8 @@ pub enum Pool {
|
||||
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.
|
||||
@@ -115,7 +117,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) => unreachable!(),
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -261,7 +263,7 @@ fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> O
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(2 + bytes.len() * 2);
|
||||
s.push_str("0x");
|
||||
for b in bytes {
|
||||
@@ -336,10 +338,13 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
|
||||
/// with their cells read as `$mode`.
|
||||
/// Streams the results of `$sql` on executor `$exec`, reading past the first `$skip`
|
||||
/// rows without decoding them, then keeping at most `$max` rows with their cells read as `$mode`.
|
||||
macro_rules! fetch {
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {{
|
||||
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
|
||||
fetch!($exec, $sql, 0usize, $max, $mode)
|
||||
};
|
||||
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
|
||||
use futures::StreamExt;
|
||||
let mut stream = sqlx::raw_sql(sqlx::AssertSqlSafe($sql.to_owned())).fetch_many($exec);
|
||||
let mut columns: Vec<ResultColumn> = Vec::new();
|
||||
@@ -348,6 +353,7 @@ macro_rules! fetch {
|
||||
let mut has_more = false;
|
||||
let mut failure = None;
|
||||
let mut first: Option<Instant> = None;
|
||||
let mut to_skip: usize = $skip;
|
||||
while let Some(item) = stream.next().await {
|
||||
first.get_or_insert_with(Instant::now);
|
||||
match item {
|
||||
@@ -356,6 +362,10 @@ macro_rules! fetch {
|
||||
if columns.is_empty() {
|
||||
columns = result_columns(&row);
|
||||
}
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= $max {
|
||||
has_more = true;
|
||||
break;
|
||||
@@ -377,7 +387,7 @@ macro_rules! fetch {
|
||||
}
|
||||
|
||||
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
|
||||
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
pub(crate) type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
|
||||
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
|
||||
@@ -385,7 +395,8 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
|
||||
/// Like [`fetch!`] for SQL Server: the first `skip` rows are read past without decoding.
|
||||
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize) -> IdeResult<Fetched> {
|
||||
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
|
||||
let first = Some(Instant::now());
|
||||
let columns = stream
|
||||
@@ -398,8 +409,13 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult
|
||||
.collect();
|
||||
let mut rows = Vec::new();
|
||||
let mut has_more = false;
|
||||
let mut to_skip = skip;
|
||||
while let Some(item) = stream.next().await {
|
||||
if let tiberius::QueryItem::Row(row) = item.map_err(mssql_err)? {
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if rows.len() >= max {
|
||||
has_more = true;
|
||||
break;
|
||||
@@ -488,6 +504,18 @@ impl Setup {
|
||||
}
|
||||
}
|
||||
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
|
||||
DbDriver::Oracle => {
|
||||
let schema = o.default_schema.trim();
|
||||
if !schema.is_empty() {
|
||||
if !is_identifier(schema) {
|
||||
return Err(IdeError::invalid(format!("invalid schema name: {schema}")));
|
||||
}
|
||||
s.statements.push(format!("ALTER SESSION SET CURRENT_SCHEMA = {schema}"));
|
||||
}
|
||||
if !o.time_zone.trim().is_empty() {
|
||||
s.statements.push(format!("ALTER SESSION SET TIME_ZONE = {}", mysql_value(o.time_zone.trim())));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for p in &source.properties {
|
||||
@@ -503,15 +531,24 @@ impl Setup {
|
||||
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
|
||||
};
|
||||
match (source.driver, name.to_ascii_lowercase().as_str()) {
|
||||
(DbDriver::Clickhouse, "statement_cache_capacity") => {
|
||||
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
|
||||
}
|
||||
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
|
||||
(_, "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()),
|
||||
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
|
||||
(DbDriver::Oracle, _) => {
|
||||
s.statements.push(format!("ALTER SESSION SET {name} = {}", mysql_value(value)))
|
||||
}
|
||||
_ => s.params.push((name, value.to_owned())),
|
||||
}
|
||||
}
|
||||
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
let script = o.startup_script.trim();
|
||||
if !script.is_empty() {
|
||||
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
|
||||
@@ -527,6 +564,16 @@ impl Pool {
|
||||
Pool::Postgres(_) => DbDriver::Postgres,
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
Pool::Oracle(_) => DbDriver::Oracle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keep-alive ping (Oracle needs a FROM clause).
|
||||
pub fn ping_sql(&self) -> &'static str {
|
||||
match self {
|
||||
Pool::Oracle(_) => "SELECT 1 FROM DUAL",
|
||||
_ => "SELECT 1",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +581,12 @@ impl Pool {
|
||||
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
const MAX: u32 = 4;
|
||||
let setup = Setup::of(source)?;
|
||||
if source.driver == DbDriver::Clickhouse {
|
||||
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
|
||||
}
|
||||
if source.driver == DbDriver::Oracle {
|
||||
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
|
||||
}
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
@@ -695,12 +748,15 @@ impl Pool {
|
||||
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
DbDriver::Oracle => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
match self {
|
||||
Pool::SqlServer(_) => {}
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
}
|
||||
@@ -708,16 +764,26 @@ impl Pool {
|
||||
pub fn is_closed(&self) -> bool {
|
||||
match self {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Oracle(p) => p.is_closed(),
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
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, max).await
|
||||
mssql_fetch(&mut conn, sql, 0, max).await
|
||||
}
|
||||
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
|
||||
};
|
||||
@@ -727,8 +793,23 @@ impl Pool {
|
||||
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
|
||||
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
self.execute_from(sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
|
||||
/// (a later page of a result: only that page is decoded and returned).
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute(self, sql, max_rows, cancel).await;
|
||||
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
conn.discard().await;
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -740,6 +821,7 @@ impl Pool {
|
||||
/// row when flagged (table editor submits).
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
let result = async {
|
||||
@@ -764,6 +846,15 @@ impl Pool {
|
||||
}
|
||||
result
|
||||
}
|
||||
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.
|
||||
conn.discard_inner();
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => with_pool!(self, p => {
|
||||
let mut tx = p.begin().await.map_err(db_err)?;
|
||||
let mut total = 0;
|
||||
@@ -790,6 +881,8 @@ pub enum Held {
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -799,6 +892,13 @@ impl Held {
|
||||
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() {
|
||||
return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"));
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -806,13 +906,21 @@ impl Held {
|
||||
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Held::Oracle(c) => {
|
||||
let sql = sql.trim();
|
||||
if sql.is_empty() {
|
||||
return Ok(()); // Oracle transactions open implicitly.
|
||||
}
|
||||
crate::oracle::run(c, sql, 0, 0, Cells::Text, &CancellationToken::new()).await.map(drop)
|
||||
}
|
||||
other => {
|
||||
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::SqlServer(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -833,6 +941,8 @@ impl Held {
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +955,22 @@ impl Held {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
self.execute_from(pool, sql, 0, max_rows, cancel).await
|
||||
}
|
||||
|
||||
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
|
||||
/// (see [`Pool::execute_from`]).
|
||||
pub async fn execute_from(
|
||||
&mut self,
|
||||
pool: &Pool,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute_from(sql, skip, max_rows, cancel).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
macro_rules! run {
|
||||
@@ -854,7 +980,7 @@ impl Held {
|
||||
None => None,
|
||||
};
|
||||
let r = tokio::select! {
|
||||
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
|
||||
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
|
||||
() = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
@@ -883,10 +1009,12 @@ impl Held {
|
||||
Held::SqlServer(c) => {
|
||||
// Tiberius has no attention/cancel API; stop reading and discard the connection.
|
||||
tokio::select! {
|
||||
r = mssql_fetch(c, sql, max_rows) => r?,
|
||||
r = mssql_fetch(c, sql, skip, max_rows) => r?,
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
@@ -907,7 +1035,8 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
|
||||
DbDriver::Oracle => "", // opens implicitly with autocommit off
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+300
-32
@@ -2,8 +2,10 @@
|
||||
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
|
||||
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
|
||||
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
@@ -212,6 +214,8 @@ pub struct DbManager {
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
|
||||
global: Option<(PathBuf, SecretStore)>,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
@@ -300,7 +304,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
|
||||
if let Err(e) = p.rows("SELECT 1", 1).await {
|
||||
if let Err(e) = p.rows(p.ping_sql(), 1).await {
|
||||
tracing::debug!("keep-alive: {}", e.message);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +342,7 @@ impl DbManager {
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
@@ -350,8 +355,42 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Also lists the global data sources stored in `dir` (the app data directory).
|
||||
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
|
||||
let passwords = self.saved_passwords.scoped(&dir);
|
||||
self.global = Some((dir, passwords));
|
||||
self
|
||||
}
|
||||
|
||||
/// The project's data sources, then the global ones (a project one wins on the same id).
|
||||
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
|
||||
source::load(&self.root)
|
||||
let mut all = source::load(&self.root)?;
|
||||
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
|
||||
all.extend(global);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
fn global_sources(&self) -> Vec<DataSource> {
|
||||
let Some((dir, _)) = &self.global else { return Vec::new() };
|
||||
source::load_global(dir).unwrap_or_else(|e| {
|
||||
tracing::warn!("global data sources: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
fn store_of(&self, source: &DataSource) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if source.global => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection settings of target `target` ([`DataSource::resolved`]), with
|
||||
@@ -371,18 +410,43 @@ 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<()> {
|
||||
let old = self.sources().unwrap_or_default();
|
||||
source::save(&self.root, &sources)?;
|
||||
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)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
gone_or_changed => {
|
||||
if gone_or_changed.is_none() {
|
||||
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
|
||||
self.session_passwords.lock().remove(&k);
|
||||
self.saved_passwords.delete(&k);
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
|
||||
match gone_or_changed {
|
||||
None => {
|
||||
for k in &keys {
|
||||
self.session_passwords.lock().remove(k);
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
// Made global or project-level: its saved passwords move along.
|
||||
Some(n) if n.global != o.global => {
|
||||
for k in &keys {
|
||||
if let Some(p) = self.store_of(&o).get(k) {
|
||||
self.store_of(n).set(k, &p)?;
|
||||
self.store_of(&o).delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
self.disconnect_source(&o.id).await;
|
||||
}
|
||||
@@ -394,7 +458,7 @@ impl DbManager {
|
||||
/// Remembers `password` for the session, and in the saved passwords when
|
||||
/// the data source saves passwords (`None` forgets it).
|
||||
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
|
||||
self.remember(id.to_owned(), password, save).await
|
||||
self.remember(id, id.to_owned(), password, save).await
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
@@ -406,10 +470,10 @@ impl DbManager {
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
self.remember(id, secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
match &secret {
|
||||
Some(p) => {
|
||||
self.session_passwords.lock().insert(key.clone(), p.clone());
|
||||
@@ -418,10 +482,11 @@ impl DbManager {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
}
|
||||
}
|
||||
let store = self.saved_for(id);
|
||||
let result = match (secret, save) {
|
||||
(Some(p), true) => self.saved_passwords.set(&key, &p),
|
||||
(Some(p), true) => store.set(&key, &p),
|
||||
_ => {
|
||||
self.saved_passwords.delete(&key);
|
||||
store.delete(&key);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -432,18 +497,18 @@ impl DbManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn secret(&self, key: String, saved: bool) -> Option<String> {
|
||||
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
|
||||
if let Some(p) = self.session_passwords.lock().get(&key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
if !saved {
|
||||
return None;
|
||||
}
|
||||
self.saved_passwords.get(&key)
|
||||
self.store_of(source).get(&key)
|
||||
}
|
||||
|
||||
async fn password(&self, source: &DataSource) -> Option<String> {
|
||||
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
|
||||
Some(p) => Some(p),
|
||||
None => source.url_password(),
|
||||
}
|
||||
@@ -499,7 +564,7 @@ impl DbManager {
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
@@ -547,12 +612,15 @@ impl DbManager {
|
||||
})?;
|
||||
let version = match source.driver {
|
||||
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
|
||||
DbDriver::Postgres => "SELECT version()",
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1",
|
||||
};
|
||||
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
|
||||
let result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
|
||||
let v = rows.into_iter().next().and_then(|r| r.into_iter().next().flatten());
|
||||
Ok(v.unwrap_or_else(|| "Connected".into()))
|
||||
}
|
||||
|
||||
@@ -701,8 +769,11 @@ impl DbManager {
|
||||
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
|
||||
}
|
||||
}
|
||||
let fk_rows =
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default();
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
|
||||
};
|
||||
let mut foreign_keys = group_foreign_keys(fk_rows, &[]);
|
||||
// SQLite keys that reference the primary key implicitly: look it up.
|
||||
for k in foreign_keys.iter_mut().filter(|k| k.ref_columns.is_empty()) {
|
||||
@@ -715,13 +786,15 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
let triggers = pool
|
||||
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let trigger_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("triggers: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let triggers = trigger_rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
@@ -822,6 +895,11 @@ impl DbManager {
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Clickhouse => {
|
||||
let q = format!("SHOW CREATE TABLE {}", sql::qualified(driver, schema, name));
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 0).into_iter().next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
let obj = format!("{}.{}", schema, name).replace('\'', "''");
|
||||
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
|
||||
@@ -829,6 +907,33 @@ impl DbManager {
|
||||
.next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
let (s, n) = (sql::quote_literal(driver, schema), sql::quote_literal(driver, name));
|
||||
let kind = pool
|
||||
.rows(
|
||||
&format!(
|
||||
"SELECT object_type FROM all_objects WHERE owner = {s} AND object_name = {n} \
|
||||
AND object_type IN ('TABLE', 'VIEW') AND ROWNUM = 1"
|
||||
),
|
||||
1,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|r| r.into_iter().next().flatten())
|
||||
.ok_or_else(missing)?;
|
||||
let ddl = cells(
|
||||
pool.rows(
|
||||
&format!("SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", kind.replace('\'', "''")),
|
||||
1,
|
||||
)
|
||||
.await?,
|
||||
0,
|
||||
)
|
||||
.into_iter()
|
||||
.next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
|
||||
let (oid, kind) = match rel.as_deref() {
|
||||
@@ -902,10 +1007,14 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
|
||||
let fk_rows = pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("foreign keys: {}", e.message);
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
});
|
||||
} else {
|
||||
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("foreign keys: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let foreign_keys = group_foreign_keys(fk_rows, &tables);
|
||||
let default_schema = match sql::current_schema(driver) {
|
||||
None => "main".to_owned(),
|
||||
@@ -929,7 +1038,20 @@ impl DbManager {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let r = self.run(id, statement, max_rows, cancel).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.
|
||||
pub async fn execute_from(
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
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),
|
||||
@@ -964,7 +1086,30 @@ impl DbManager {
|
||||
/// 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();
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
|
||||
if driver == DbDriver::Oracle {
|
||||
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
|
||||
let plan_id = format!(
|
||||
"OPENIDEA{}",
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos())
|
||||
);
|
||||
self.run(
|
||||
id,
|
||||
&format!(
|
||||
"EXPLAIN PLAN SET STATEMENT_ID = '{}' FOR {}",
|
||||
plan_id,
|
||||
statement.trim().trim_end_matches(';')
|
||||
),
|
||||
0,
|
||||
MAX_META_ROWS,
|
||||
cancel,
|
||||
)
|
||||
.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).await;
|
||||
return nodes;
|
||||
}
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -972,14 +1117,18 @@ impl DbManager {
|
||||
&self,
|
||||
id: &str,
|
||||
statement: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
let pool = self.pool(id).await?;
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else {
|
||||
return pool.execute(statement, max_rows, cancel).await;
|
||||
return pool.execute_from(statement, skip, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
let mut held = session.lock().await;
|
||||
if held.is_none() {
|
||||
let mut conn = Held::acquire(&pool).await?;
|
||||
@@ -987,7 +1136,7 @@ impl DbManager {
|
||||
*held = Some(conn);
|
||||
}
|
||||
let conn = held.as_mut().expect("opened above");
|
||||
let r = conn.execute(&pool, statement, max_rows, cancel).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.
|
||||
held.take().expect("opened above").discard().await;
|
||||
@@ -998,8 +1147,11 @@ impl DbManager {
|
||||
|
||||
/// Switches `id` between auto-commit and manual commit. Leaving manual
|
||||
/// mode rolls back what was not committed.
|
||||
pub async fn set_manual_commit(&self, id: &str, manual: bool) {
|
||||
pub async fn set_manual_commit(&self, id: &str, manual: bool) -> IdeResult<()> {
|
||||
if manual {
|
||||
if self.source(id)?.driver == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
self.sessions.lock().entry(id.to_owned()).or_default();
|
||||
} else {
|
||||
let session = self.sessions.lock().remove(id);
|
||||
@@ -1007,10 +1159,14 @@ impl DbManager {
|
||||
end_session(&s).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits (or rolls back) the open transaction of a manual-commit data source.
|
||||
pub async fn finish_transaction(&self, id: &str, commit: bool) -> IdeResult<()> {
|
||||
if self.source(id).is_ok_and(|s| s.driver == DbDriver::Clickhouse) {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
@@ -1052,6 +1208,9 @@ impl DbManager {
|
||||
|
||||
/// Applies the table editor's pending changes in one transaction.
|
||||
pub async fn apply_edits(&self, id: &str, schema: &str, table: &str, edits: &[DbRowEdit]) -> IdeResult<u64> {
|
||||
if self.source(id)?.driver == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse table editing is unsupported"));
|
||||
}
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let statements = edits
|
||||
@@ -1064,3 +1223,112 @@ impl DbManager {
|
||||
pool.transaction(&statements).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_ssh::secrets::testing::files_containing;
|
||||
use std::path::Path;
|
||||
|
||||
fn manager(dir: &Path) -> DbManager {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let data = dir.join("data");
|
||||
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
|
||||
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_passwords_stay_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
|
||||
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
|
||||
let reopened = manager(tmp.path());
|
||||
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
|
||||
assert_eq!(
|
||||
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
|
||||
Some("hunter2-tunnel")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project").to_string_lossy().into_owned();
|
||||
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
|
||||
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
|
||||
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
|
||||
let m = manager(tmp.path());
|
||||
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
|
||||
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
|
||||
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
|
||||
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
|
||||
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
|
||||
let manager = |root: &std::path::Path| {
|
||||
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
|
||||
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
|
||||
.with_global_sources(app.clone())
|
||||
};
|
||||
let (a, b) = (manager(&one), manager(&two));
|
||||
let source = |id: &str, global: bool| DataSource {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
driver: DbDriver::Postgres,
|
||||
save_password: true,
|
||||
global,
|
||||
..Default::default()
|
||||
};
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
a.set_password("shared", Some("pw".into()), true).await.unwrap();
|
||||
// Made global: listed in the other project too, with its saved password.
|
||||
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
|
||||
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
|
||||
assert_eq!(ids(&b), [("shared".into(), true)]);
|
||||
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
|
||||
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
|
||||
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
|
||||
// Made project-level again: gone from the other project, the password stays with it.
|
||||
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
|
||||
assert!(b.sources().unwrap().is_empty());
|
||||
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = Arc::new(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: "clickhouse".into(),
|
||||
name: "clickhouse".into(),
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(1),
|
||||
..Default::default()
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
let error = db.set_manual_commit("clickhouse", true).await.unwrap_err();
|
||||
assert!(error.message.contains("manual transactions are unsupported"));
|
||||
assert!(db.sessions.lock().is_empty());
|
||||
for commit in [false, true] {
|
||||
let error = db.finish_transaction("clickhouse", commit).await.unwrap_err();
|
||||
assert!(error.message.contains("manual transactions are unsupported"));
|
||||
}
|
||||
let error = db.apply_edits("clickhouse", "default", "events", &[]).await.unwrap_err();
|
||||
assert!(error.message.contains("table editing is unsupported"));
|
||||
assert!(!db.is_connected("clickhouse"));
|
||||
db.set_manual_commit("clickhouse", false).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Oracle connections through the synchronous `rust-oracle` driver (ODPI-C).
|
||||
//! The Oracle client library (Instant Client) is loaded once per process from
|
||||
//! the Options tab or `ORACLE_HOME`; every blocking call runs on the blocking
|
||||
//! pool, and cancelling a statement closes its connection.
|
||||
|
||||
use crate::conn::{Cells, Fetched, ResultColumn, Setup, hex};
|
||||
use crate::source::{DataSource, DbDriver, DbTxMode};
|
||||
use bb8::ManageConnection;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use oracle::sql_type::{Blob, Clob, OracleType};
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Characters of a LOB column shown in the grid; catalog text reads more.
|
||||
const MAX_GRID_LOB: u64 = 4096;
|
||||
const MAX_TEXT_LOB: u64 = 1 << 16;
|
||||
|
||||
/// Everything a new connection needs, built once per pool.
|
||||
#[derive(Debug)]
|
||||
struct State {
|
||||
user: String,
|
||||
password: String,
|
||||
/// EZConnect `//host:port/service`, a TNS descriptor or an alias.
|
||||
connect: String,
|
||||
init: Vec<String>,
|
||||
auto_commit: bool,
|
||||
read_only: bool,
|
||||
statement_cache: Option<u32>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
|
||||
let user = source.user.trim();
|
||||
if user.is_empty() {
|
||||
return Err(IdeError::invalid("Oracle data sources need a user name"));
|
||||
}
|
||||
Ok(Self {
|
||||
user: user.to_owned(),
|
||||
password: password.unwrap_or_default().to_owned(),
|
||||
connect: connect_string(source),
|
||||
init: setup.statements.clone(),
|
||||
auto_commit: source.options.transaction != DbTxMode::Manual,
|
||||
read_only: source.read_only,
|
||||
statement_cache: setup.statement_cache.map(|n| n as u32),
|
||||
timeout: setup.connect_timeout.unwrap_or(Duration::from_secs(30)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `//host:port/service`; the database field may also hold a TNS descriptor or alias.
|
||||
fn connect_string(source: &DataSource) -> String {
|
||||
let db = source.database.trim();
|
||||
if db.starts_with('(') || db.contains('=') {
|
||||
return db.to_owned();
|
||||
}
|
||||
let host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
|
||||
let port = source.port.unwrap_or(1521);
|
||||
if db.is_empty() { format!("//{host}:{port}") } else { format!("//{host}:{port}/{db}") }
|
||||
}
|
||||
|
||||
/// The expanded Instant Client directory, `None` when the search path is used.
|
||||
fn client_dir(dir: &str) -> IdeResult<Option<String>> {
|
||||
let dir = dir.trim();
|
||||
if dir.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let expanded: PathBuf = ide_ssh::expand(dir);
|
||||
if !expanded.is_dir() {
|
||||
return Err(IdeError::invalid(format!("Oracle Instant Client directory not found: {}", expanded.display())));
|
||||
}
|
||||
Ok(Some(expanded.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Loads the Oracle client once per process; the first data source's directory wins.
|
||||
/// Failures are not cached: a corrected directory works without restarting.
|
||||
fn init_client(dir: &str) -> IdeResult<()> {
|
||||
static LOADED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
let dir = dir.trim();
|
||||
let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(used) = loaded.as_deref() {
|
||||
if !dir.is_empty() && !used.is_empty() && used != dir {
|
||||
tracing::warn!("Oracle client already loaded from {used}; ignoring {dir}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut params = oracle::InitParams::new();
|
||||
if let Some(lib_dir) = client_dir(dir)? {
|
||||
params
|
||||
.oracle_client_lib_dir(lib_dir)
|
||||
.map_err(|e| IdeError::invalid(format!("Oracle Instant Client directory: {e}")))?;
|
||||
}
|
||||
params.init().map_err(|e| {
|
||||
let hint = if e.to_string().contains("DPI-1047") {
|
||||
format!("{e} — set the Instant Client directory (Options tab) or ORACLE_HOME")
|
||||
} else {
|
||||
e.to_string()
|
||||
};
|
||||
IdeError::new(ErrorKind::Io, format!("Oracle client: {hint}"))
|
||||
})?;
|
||||
*loaded = Some(dir.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `rust-oracle` errors are not `Sync`; the pool wants a shareable error.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OracleError(Arc<oracle::Error>);
|
||||
|
||||
impl OracleError {
|
||||
fn new(e: oracle::Error) -> Self {
|
||||
Self(Arc::new(e))
|
||||
}
|
||||
|
||||
fn message(msg: impl Into<String>) -> Self {
|
||||
Self::new(oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.into()))
|
||||
}
|
||||
|
||||
fn into_ide(self) -> IdeError {
|
||||
oracle_err(clone_db(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Database errors (the interesting ones) rebuild cheaply for cloning.
|
||||
fn clone_db(e: &oracle::Error) -> oracle::Error {
|
||||
match e.db_error() {
|
||||
Some(db) => oracle::Error::new(oracle::ErrorKind::OciError, db.message().to_owned()),
|
||||
None => message_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_error(msg: &str) -> oracle::Error {
|
||||
oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.to_owned())
|
||||
}
|
||||
|
||||
/// Wrong or missing password: ORA-01017 / ORA-01005; unreachable server: ORA-12xxx / ORA-31xx.
|
||||
pub(crate) fn oracle_err(e: oracle::Error) -> IdeError {
|
||||
let Some(db) = e.db_error() else {
|
||||
return IdeError::new(ErrorKind::InvalidInput, e.to_string());
|
||||
};
|
||||
let msg = db.message().to_owned();
|
||||
if msg.contains("DPI-1047") {
|
||||
return IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("{msg} — set the Instant Client directory (Options tab) or ORACLE_HOME"),
|
||||
);
|
||||
}
|
||||
let code = db.code();
|
||||
let full = format!("[ORA-{code:05}] {msg}");
|
||||
match code {
|
||||
1005 | 1017 | 28000 | 28001 => IdeError::new(ErrorKind::PermissionDenied, full),
|
||||
12150..=12199 | 12537 | 12540 | 12541 | 12543 | 12545 | 3113 | 3114 => IdeError::new(ErrorKind::Io, full),
|
||||
_ => IdeError::new(ErrorKind::InvalidInput, full),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OraclePool {
|
||||
state: Arc<State>,
|
||||
pool: Arc<bb8::Pool<OracleConnectionManager>>,
|
||||
}
|
||||
|
||||
impl OraclePool {
|
||||
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
|
||||
init_client(&source.options.instant_client)?;
|
||||
let state = Arc::new(State::of(source, password, setup)?);
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(4)
|
||||
.connection_timeout(state.timeout)
|
||||
.build(OracleConnectionManager(state.clone()))
|
||||
.await
|
||||
.map_err(|e| e.into_ide())?;
|
||||
// bb8 connects lazily and reports failed attempts as a bare timeout, which
|
||||
// would turn a wrong password into "timed out connecting to the database".
|
||||
let opened = tokio::task::spawn_blocking({
|
||||
let state = state.clone();
|
||||
move || open(&state)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
|
||||
.map_err(oracle_err)?;
|
||||
let _ = pool.add(OracleHandle { conn: Some(opened) });
|
||||
Ok(Self { state, pool: Arc::new(pool) })
|
||||
}
|
||||
|
||||
/// A connection taken out of the pool for one statement or a held session.
|
||||
pub async fn get(&self) -> IdeResult<OracleConnection> {
|
||||
let pooled = self.pool.get_owned().await.map_err(|e| pool_err(e).into_ide())?;
|
||||
Ok(OracleConnection { state: self.state.clone(), pooled })
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
let state = self.pool.state();
|
||||
state.connections == 0 && state.idle_connections == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
|
||||
match e {
|
||||
bb8::RunError::User(e) => e,
|
||||
bb8::RunError::TimedOut => OracleError::message("timed out connecting to the database"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pooled connection's payload; `conn` is `None` once the connection is
|
||||
/// discarded (a cancelled statement leaves it in the blocking task).
|
||||
pub struct OracleHandle {
|
||||
conn: Option<oracle::Connection>,
|
||||
}
|
||||
|
||||
pub struct OracleConnectionManager(Arc<State>);
|
||||
|
||||
impl ManageConnection for OracleConnectionManager {
|
||||
type Connection = OracleHandle;
|
||||
type Error = OracleError;
|
||||
|
||||
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
||||
let state = self.0.clone();
|
||||
let opened = tokio::task::spawn_blocking(move || open(&state))
|
||||
.await
|
||||
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
|
||||
let conn = opened.map_err(OracleError::new)?;
|
||||
Ok(OracleHandle { conn: Some(conn) })
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
let state = self.0.clone();
|
||||
let Some(raw) = conn.conn.take() else {
|
||||
return Err(OracleError::message("Oracle connection is closed"));
|
||||
};
|
||||
let ping = tokio::time::timeout(
|
||||
state.timeout,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = (|| -> oracle::Result<()> {
|
||||
// `execute` rejects selects, so read one row.
|
||||
let mut stmt = raw.statement("SELECT 1 FROM DUAL").build()?;
|
||||
let mut rows = stmt.query(&[])?;
|
||||
if let Some(row) = rows.next() {
|
||||
row?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
(raw, result)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match ping {
|
||||
Ok(Ok((raw, Ok(())))) => {
|
||||
conn.conn = Some(raw);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Ok((_, Err(e)))) => Err(OracleError::new(e)),
|
||||
Ok(Err(e)) => Err(OracleError::message(format!("Oracle ping task: {e}"))),
|
||||
// Timed out (the task keeps the connection and closes it): discard this one.
|
||||
Err(_) => Err(OracleError::message("timed out waiting for the Oracle server")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
|
||||
conn.conn.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection borrowed from the pool. It returns to the pool when dropped,
|
||||
/// unless its statement was cancelled (then it is discarded).
|
||||
pub struct OracleConnection {
|
||||
state: Arc<State>,
|
||||
pooled: bb8::PooledConnection<'static, OracleConnectionManager>,
|
||||
}
|
||||
|
||||
impl OracleConnection {
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.pooled.conn.is_none()
|
||||
}
|
||||
|
||||
/// Drops the connection instead of returning it; the server rolls an open
|
||||
/// transaction back.
|
||||
pub(crate) fn discard_inner(&mut self) {
|
||||
self.pooled.conn.take();
|
||||
}
|
||||
}
|
||||
|
||||
fn open(state: &State) -> oracle::Result<oracle::Connection> {
|
||||
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
|
||||
connector.driver_name("OpenIDEA");
|
||||
if let Some(n) = state.statement_cache {
|
||||
connector.stmt_cache_size(n);
|
||||
}
|
||||
let mut conn = connector.connect()?;
|
||||
conn.set_autocommit(state.auto_commit);
|
||||
for sql in &state.init {
|
||||
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
|
||||
/// connection instead of returning it (its statement keeps running server-side).
|
||||
async fn with_conn<T>(
|
||||
c: &mut OracleConnection,
|
||||
cancel: &CancellationToken,
|
||||
f: impl FnOnce(&mut oracle::Connection) -> IdeResult<T> + Send + 'static,
|
||||
) -> IdeResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let Some(conn) = c.pooled.conn.take() else {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Oracle connection is closed"));
|
||||
};
|
||||
let (tx, mut rx) = mpsc::channel::<(oracle::Connection, IdeResult<T>)>(1);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut slot = Some(conn);
|
||||
let result = match slot.as_mut() {
|
||||
Some(conn) => f(conn),
|
||||
None => unreachable!("connection taken above"),
|
||||
};
|
||||
// A closed receiver (cancelled) drops the connection, closing it.
|
||||
let _ = tx.blocking_send((slot.take().expect("some"), result));
|
||||
});
|
||||
tokio::select! {
|
||||
item = rx.recv() => match item {
|
||||
Some((conn, result)) => {
|
||||
c.pooled.conn = Some(conn);
|
||||
result
|
||||
}
|
||||
None => Err(IdeError::internal("the Oracle connection task failed")),
|
||||
},
|
||||
() = cancel.cancelled() => {
|
||||
rx.close();
|
||||
Err(IdeError::cancelled())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a script on `c` (see [`crate::conn::Pool::rows`] and `Held::execute`).
|
||||
pub(crate) async fn run(
|
||||
c: &mut OracleConnection,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max: usize,
|
||||
mode: Cells,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Fetched> {
|
||||
let state = c.state.clone();
|
||||
let sql = sql.to_owned();
|
||||
with_conn(c, cancel, move |conn| statements(conn, &state, &sql, skip, max, mode)).await
|
||||
}
|
||||
|
||||
/// The table editor's edits in one transaction.
|
||||
pub(crate) async fn transaction(c: &mut OracleConnection, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
if c.state.read_only {
|
||||
return Err(IdeError::new(ErrorKind::PermissionDenied, "read-only connection: edits are not allowed"));
|
||||
}
|
||||
let script = statements.iter().map(|(sql, _)| sql.clone()).collect::<Vec<_>>();
|
||||
let expect: Vec<bool> = statements.iter().map(|(_, e)| *e).collect();
|
||||
let apply = move |conn: &mut oracle::Connection| -> IdeResult<u64> {
|
||||
conn.set_autocommit(false);
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in script.iter().zip(&expect) {
|
||||
let mut stmt = conn.statement(sql).build().map_err(oracle_err)?;
|
||||
stmt.execute(&[]).map_err(oracle_err)?;
|
||||
let n = stmt.row_count().map_err(oracle_err)?;
|
||||
if *expect_one && n != 1 {
|
||||
conn.rollback().ok();
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
|
||||
)));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
conn.commit().map_err(oracle_err)?;
|
||||
Ok(total)
|
||||
};
|
||||
let cancel = CancellationToken::new();
|
||||
with_conn(c, &cancel, apply).await
|
||||
}
|
||||
|
||||
/// Runs every statement of `sql` (one script, PL/SQL blocks kept whole): the
|
||||
/// first query's rows are the result, otherwise the total rows changed.
|
||||
fn statements(
|
||||
conn: &mut oracle::Connection,
|
||||
state: &State,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max: usize,
|
||||
mode: Cells,
|
||||
) -> IdeResult<Fetched> {
|
||||
conn.set_autocommit(state.auto_commit);
|
||||
let mut query: Option<Fetched> = None;
|
||||
let mut affected: u64 = 0;
|
||||
let mut first: Option<Instant> = None;
|
||||
for s in crate::split::split(sql, DbDriver::Oracle) {
|
||||
if state.read_only && is_write(&s.text) {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!("read-only connection: {} statements are not allowed", first_word(&s.text)),
|
||||
));
|
||||
}
|
||||
let r = statement(conn, &s.text, skip, max, mode)?;
|
||||
first.get_or_insert_with(|| r.4.unwrap_or_else(Instant::now));
|
||||
if r.0.is_empty() {
|
||||
affected += r.2.unwrap_or(0);
|
||||
} else if query.is_none() {
|
||||
query = Some(r);
|
||||
}
|
||||
}
|
||||
Ok(query.unwrap_or((Vec::new(), Vec::new(), Some(affected), false, first)))
|
||||
}
|
||||
|
||||
/// Reads one statement; `skip` rows are fetched and dropped (result pagination).
|
||||
fn statement(conn: &mut oracle::Connection, sql: &str, skip: usize, max: usize, mode: Cells) -> IdeResult<Fetched> {
|
||||
let started = Instant::now();
|
||||
// LOB locators let `cell` read only the shown prefix (the default fetches whole LOBs).
|
||||
let mut stmt = conn
|
||||
.statement(sql)
|
||||
.fetch_array_size((max as u32).clamp(1, 1000) + 1)
|
||||
.lob_locator()
|
||||
.build()
|
||||
.map_err(oracle_err)?;
|
||||
match stmt.statement_type() {
|
||||
oracle::StatementType::Select => {
|
||||
let rows = stmt.query(&[]).map_err(oracle_err)?;
|
||||
let columns: Vec<ResultColumn> = rows
|
||||
.column_info()
|
||||
.iter()
|
||||
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.oracle_type().to_string() })
|
||||
.collect();
|
||||
let mut out: Vec<Vec<Option<String>>> = Vec::new();
|
||||
let mut has_more = false;
|
||||
let mut to_skip = skip;
|
||||
for row in rows {
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if out.len() >= max {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
let row = row.map_err(oracle_err)?;
|
||||
out.push(row.sql_values().iter().map(|v| cell(v, mode)).collect());
|
||||
}
|
||||
Ok((columns, out, None, has_more, Some(started)))
|
||||
}
|
||||
_ => {
|
||||
stmt.execute(&[]).map_err(oracle_err)?;
|
||||
let affected = stmt.row_count().map_err(oracle_err)?;
|
||||
Ok((Vec::new(), Vec::new(), Some(affected), false, Some(started)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_word(sql: &str) -> String {
|
||||
sql.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with("--"))
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Whether `sql` changes data or schema (read-only connections refuse it).
|
||||
fn is_write(sql: &str) -> bool {
|
||||
!matches!(first_word(sql).as_str(), "SELECT" | "WITH" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "SET")
|
||||
}
|
||||
|
||||
fn cell(v: &oracle::SqlValue, mode: Cells) -> Option<String> {
|
||||
if v.is_null().unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
match v.oracle_type().ok() {
|
||||
Some(OracleType::CLOB | OracleType::NCLOB) => {
|
||||
let lob: Clob = v.get().ok()?;
|
||||
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
|
||||
let mut s = String::new();
|
||||
lob.take(cap).read_to_string(&mut s).ok()?;
|
||||
Some(s)
|
||||
}
|
||||
Some(OracleType::BLOB) => {
|
||||
let lob: Blob = v.get().ok()?;
|
||||
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
|
||||
let mut buf = Vec::new();
|
||||
lob.take(cap).read_to_end(&mut buf).ok()?;
|
||||
Some(hex(&buf))
|
||||
}
|
||||
_ => v.get::<String>().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_strings() {
|
||||
let s = |db: &str| DataSource {
|
||||
driver: DbDriver::Oracle,
|
||||
host: "db.local".into(),
|
||||
port: Some(1521),
|
||||
database: db.into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(connect_string(&s("ORCLPDB1")), "//db.local:1521/ORCLPDB1");
|
||||
assert_eq!(connect_string(&s("")), "//db.local:1521");
|
||||
assert_eq!(
|
||||
connect_string(&DataSource { host: String::new(), port: None, database: "ORCL".into(), ..s("") }),
|
||||
"//localhost:1521/ORCL"
|
||||
);
|
||||
assert_eq!(connect_string(&s("(DESCRIPTION=(ADDRESS=...))")), "(DESCRIPTION=(ADDRESS=...))");
|
||||
assert_eq!(connect_string(&s("PROD=MYDB")), "PROD=MYDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_detection() {
|
||||
assert!(!is_write("SELECT 1 FROM DUAL"));
|
||||
assert!(!is_write("-- x\nWITH q AS (SELECT 1) SELECT * FROM q"));
|
||||
assert!(!is_write("DESC users"));
|
||||
assert!(is_write("INSERT INTO t VALUES (1)"));
|
||||
assert!(is_write("begin\n null;\nend;"));
|
||||
assert!(is_write("DROP TABLE t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_user_is_rejected_before_connecting() {
|
||||
let setup = Setup::default();
|
||||
let err = State::of(&DataSource { driver: DbDriver::Oracle, ..Default::default() }, None, &setup).unwrap_err();
|
||||
assert!(err.message.contains("user name"));
|
||||
let state = State::of(
|
||||
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
|
||||
Some("tiger"),
|
||||
&setup,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(state.user.as_str(), state.password.as_str(), state.connect.as_str()),
|
||||
("scott", "tiger", "//localhost:1521")
|
||||
);
|
||||
assert!(state.auto_commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_errors_map_to_kinds() {
|
||||
let auth = oracle_err(message_error("x")).kind; // no database error: invalid input
|
||||
assert_eq!(auth, ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_discarded_connection_is_broken_but_a_live_one_is_not() {
|
||||
// No server is reachable here, so the pooled connection is a husk: `has_broken`
|
||||
// decides whether bb8 keeps it, which is what cancellation relies on.
|
||||
let manager = OracleConnectionManager(Arc::new(
|
||||
State::of(
|
||||
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
|
||||
None,
|
||||
&Setup::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
let mut broken = OracleHandle { conn: None };
|
||||
assert!(manager.has_broken(&mut broken));
|
||||
// A connection that cannot be opened reports the failure without panicking
|
||||
// (a client-load error, or ORA-12541 when a client is available).
|
||||
match manager.connect().await {
|
||||
Ok(_) => unreachable!("no server is listening"),
|
||||
Err(err) => assert!(!err.into_ide().message.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_reports_a_missing_client_directory() {
|
||||
let err = client_dir("/nonexistent/instantclient").expect_err("no such directory");
|
||||
assert!(err.message.contains("Instant Client directory not found"), "{}", err.message);
|
||||
assert!(client_dir("").expect("the search path").is_none());
|
||||
}
|
||||
|
||||
/// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is
|
||||
/// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the
|
||||
/// Instant Client when it is not on the search path).
|
||||
///
|
||||
/// Checking out a connection runs `is_valid`, so this fails if the ping is
|
||||
/// not a query (bb8 silently retries a rejected check-out until it times out).
|
||||
#[tokio::test]
|
||||
async fn a_pooled_connection_runs_queries() {
|
||||
let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return };
|
||||
let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service");
|
||||
let (user, password) = auth.split_once('/').expect("user/password");
|
||||
let (host, rest) = addr.split_once(':').expect("host:port/service");
|
||||
let (port, service) = rest.split_once('/').expect("port/service");
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Oracle,
|
||||
host: host.into(),
|
||||
port: Some(port.parse().expect("port")),
|
||||
database: service.into(),
|
||||
user: user.into(),
|
||||
options: crate::source::DbOptions {
|
||||
instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// A missing or wrong password asks for one (the UI prompts on PermissionDenied).
|
||||
for wrong in [None, Some("wrong")] {
|
||||
let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") };
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message);
|
||||
}
|
||||
let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect");
|
||||
let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool");
|
||||
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1"));
|
||||
|
||||
// The result grid reads later pages by skipping rows client-side.
|
||||
let page = pool
|
||||
.execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new())
|
||||
.await
|
||||
.expect("second page");
|
||||
let values: Vec<String> = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect();
|
||||
assert_eq!(values, ["5", "6", "7"]);
|
||||
assert!(page.has_more);
|
||||
|
||||
// PL/SQL units reach the server with their closing `END;`.
|
||||
pool.rows("begin null; end;", 0).await.expect("anonymous block");
|
||||
pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block");
|
||||
let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;";
|
||||
let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query");
|
||||
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7"));
|
||||
pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure");
|
||||
let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\
|
||||
create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/";
|
||||
pool.rows(package, 0).await.expect("package spec and body");
|
||||
let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package");
|
||||
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8"));
|
||||
pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up");
|
||||
|
||||
// LOB cells are read through locators.
|
||||
let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs");
|
||||
assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())]));
|
||||
}
|
||||
}
|
||||
+110
-1
@@ -35,16 +35,32 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
|
||||
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
|
||||
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`).
|
||||
pub fn oracle_plan_query(plan_id: &str) -> String {
|
||||
format!(
|
||||
"SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \
|
||||
FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id"
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes a read plan (PLAN_TABLE keeps rows until the session ends).
|
||||
pub fn oracle_plan_cleanup(plan_id: &str) -> String {
|
||||
format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'")
|
||||
}
|
||||
|
||||
/// Reads the result of [`explain_statement`].
|
||||
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
|
||||
match driver {
|
||||
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
|
||||
DbDriver::Postgres => {
|
||||
DbDriver::Oracle => Ok(oracle(&result.rows)),
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => {
|
||||
let text: String =
|
||||
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
|
||||
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
|
||||
@@ -147,6 +163,40 @@ fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`.
|
||||
fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
|
||||
let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4));
|
||||
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
|
||||
let op = format!("{operation} {options}").trim().to_owned();
|
||||
let (operation, params) = match (operation.as_str(), options.as_str()) {
|
||||
("TABLE ACCESS", "FULL") => ("Full Scan".into(), object),
|
||||
("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object),
|
||||
("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object),
|
||||
("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object),
|
||||
("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object),
|
||||
("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object),
|
||||
("SORT", "ORDER BY") => ("Order By".into(), String::new()),
|
||||
("SORT", "GROUP BY") => ("Group By".into(), String::new()),
|
||||
("SORT", "UNIQUE") => ("Distinct".into(), String::new()),
|
||||
("HASH", "GROUP BY") => ("Group By".into(), String::new()),
|
||||
_ => (op, object),
|
||||
};
|
||||
let mut properties = Vec::new();
|
||||
for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] {
|
||||
if let Some(v) = r.get(value).cloned().flatten() {
|
||||
properties.push(prop(name, v));
|
||||
}
|
||||
}
|
||||
out.push(PlanNode { parent, operation, params, properties });
|
||||
ids.push(id);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
|
||||
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let col = |r: &[Option<String>], n: &str| {
|
||||
@@ -199,7 +249,51 @@ mod tests {
|
||||
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
|
||||
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1");
|
||||
let q = oracle_plan_query("OI1");
|
||||
assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id"));
|
||||
assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_oracle_plan_table_rows() {
|
||||
let r = result(
|
||||
&["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"],
|
||||
vec![
|
||||
vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")],
|
||||
vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")],
|
||||
vec![
|
||||
Some("4"),
|
||||
Some("2"),
|
||||
Some("TABLE ACCESS"),
|
||||
Some("FULL"),
|
||||
Some("orders"),
|
||||
Some("2"),
|
||||
Some("41"),
|
||||
Some("1230"),
|
||||
],
|
||||
vec![
|
||||
Some("5"),
|
||||
Some("2"),
|
||||
Some("INDEX"),
|
||||
Some("UNIQUE SCAN"),
|
||||
Some("c_pk"),
|
||||
Some("0"),
|
||||
Some("1"),
|
||||
Some("6"),
|
||||
],
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Oracle, &r).unwrap();
|
||||
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
|
||||
assert_eq!(
|
||||
ops,
|
||||
[("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")]
|
||||
);
|
||||
assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1)));
|
||||
assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -254,6 +348,21 @@ mod tests {
|
||||
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_clickhouse_json_plans_and_rejects_invalid_json() {
|
||||
let json = r#"[{"Plan": {"Node Type": "Expression", "Node Id": "Expression_5",
|
||||
"Description": "Project names", "Plans": [
|
||||
{"Node Type": "ReadFromMergeTree", "Node Id": "ReadFromMergeTree_0",
|
||||
"Description": "default.events"}
|
||||
]}}]"#;
|
||||
let plan = parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some(json)]])).unwrap();
|
||||
assert_eq!(plan.len(), 2);
|
||||
assert_eq!((plan[0].parent, plan[0].operation.as_str()), (None, "Expression"));
|
||||
assert_eq!((plan[1].parent, plan[1].operation.as_str()), (Some(0), "ReadFromMergeTree"));
|
||||
assert!(plan[1].properties.iter().any(|p| p.name == "Description" && p.value == "default.events"));
|
||||
assert!(parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some("invalid")]])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mysql_rows() {
|
||||
let r = result(
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
//! Saved data source passwords, in the app's data directory rather than the OS
|
||||
//! credential store (no system prompts). One JSON file for all projects, keyed
|
||||
//! by project root, then credential key; owner-only on Unix.
|
||||
//! Saved data source passwords, in the OS credential store like the other saved
|
||||
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
|
||||
//! keyed by project root, then credential key.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use ide_core::IdeResult;
|
||||
use ide_ssh::secrets::SecretFile;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Project root → credential key → secret.
|
||||
type Contents = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
/// Serialises read-modify-write across the projects sharing the file.
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub struct SecretStore {
|
||||
path: PathBuf,
|
||||
file: SecretFile,
|
||||
project: String,
|
||||
}
|
||||
|
||||
@@ -23,61 +18,41 @@ pub fn ssh_key(id: &str) -> String {
|
||||
format!("{id}#ssh")
|
||||
}
|
||||
|
||||
fn file_key(project: &str, key: &str) -> String {
|
||||
format!("{project}|{key}")
|
||||
}
|
||||
|
||||
/// Older versions' file: project root → credential key → secret.
|
||||
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
|
||||
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
|
||||
Some(
|
||||
all.into_iter()
|
||||
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
impl SecretStore {
|
||||
pub fn new(path: PathBuf, root: &Path) -> Self {
|
||||
Self { path, project: root.to_string_lossy().into_owned() }
|
||||
let file = SecretFile::with_legacy(path.clone(), legacy);
|
||||
Self { path, file, project: root.to_string_lossy().into_owned() }
|
||||
}
|
||||
|
||||
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
|
||||
pub fn scoped(&self, scope: &Path) -> Self {
|
||||
Self::new(self.path.clone(), scope)
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = LOCK.lock();
|
||||
self.read().remove(&self.project)?.remove(key)
|
||||
self.file.get(&file_key(&self.project, key))
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
self.file.set(&file_key(&self.project, key), secret)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = LOCK.lock();
|
||||
let mut all = self.read();
|
||||
let Some(project) = all.get_mut(&self.project) else { return };
|
||||
if project.remove(key).is_none() {
|
||||
return;
|
||||
}
|
||||
if project.is_empty() {
|
||||
all.remove(&self.project);
|
||||
}
|
||||
if let Err(e) = self.write(&all) {
|
||||
tracing::warn!("saved passwords: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> Contents {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved passwords: {}: {e}", self.path.display());
|
||||
Contents::new()
|
||||
}),
|
||||
Err(_) => Contents::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &Contents) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
self.file.delete(&file_key(&self.project, key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +62,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn keeps_projects_apart_and_drops_empty_ones() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("db-passwords.json");
|
||||
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
|
||||
|
||||
+99
-12
@@ -1,6 +1,7 @@
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
|
||||
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
|
||||
//! only in memory for the session.
|
||||
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
|
||||
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
|
||||
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
|
||||
//! the others only in memory for the session.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -18,6 +19,9 @@ pub enum DbDriver {
|
||||
Mysql,
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -27,6 +31,8 @@ impl DbDriver {
|
||||
DbDriver::Postgres => Some(5432),
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,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 `?`.
|
||||
@@ -179,6 +187,9 @@ pub struct DataSource {
|
||||
pub options: DbOptions,
|
||||
pub introspection: DbIntrospection,
|
||||
pub properties: Vec<DbProperty>,
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -207,6 +218,7 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +265,8 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"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),
|
||||
"oracle" | "oracledb" => Some(DbDriver::Oracle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -260,11 +274,17 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
|
||||
/// included, and `sqlite:path`.
|
||||
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
|
||||
let bad = || IdeError::invalid("invalid database URL");
|
||||
let url = raw.trim();
|
||||
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
|
||||
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
|
||||
let driver = scheme_driver(scheme).ok_or_else(bad)?;
|
||||
// JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service` (after the `jdbc:` prefix).
|
||||
let rest = if driver == DbDriver::Oracle {
|
||||
rest.strip_prefix("thin:@").or_else(|| rest.strip_prefix("thin:/@")).unwrap_or(rest)
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
if driver == DbDriver::Sqlite {
|
||||
let file = rest.strip_prefix("//").unwrap_or(rest);
|
||||
let file = file.split('?').next().unwrap_or_default();
|
||||
@@ -367,6 +387,15 @@ impl DataSource {
|
||||
}
|
||||
properties.append(&mut s.properties);
|
||||
s.properties = properties;
|
||||
if s.driver == DbDriver::Clickhouse {
|
||||
let raw_url = self.url.trim().to_ascii_lowercase();
|
||||
let url = raw_url.strip_prefix("jdbc:").unwrap_or(&raw_url);
|
||||
if url.starts_with("https://") {
|
||||
s.ssl = DbSslMode::VerifyFull;
|
||||
} else if url.starts_with("http://") {
|
||||
s.ssl = DbSslMode::Disable;
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -385,9 +414,32 @@ struct SourcesFile {
|
||||
sources: Vec<DataSource>,
|
||||
}
|
||||
|
||||
/// The project's data sources.
|
||||
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, FILE);
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
read(&project_dir::path(root, FILE))
|
||||
}
|
||||
|
||||
/// Saves the project's data sources.
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&project_dir::path(root, FILE), sources)
|
||||
}
|
||||
|
||||
/// The global data sources stored in `dir` (the app data directory).
|
||||
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let mut sources = read(&dir.join(FILE))?;
|
||||
for s in &mut sources {
|
||||
s.global = true;
|
||||
}
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Saves the global data sources in `dir`.
|
||||
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
write(&dir.join(FILE), sources)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -396,19 +448,23 @@ pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
|
||||
Ok(file.sources)
|
||||
}
|
||||
|
||||
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
|
||||
for s in sources {
|
||||
if s.id.is_empty() || s.name.trim().is_empty() {
|
||||
return Err(IdeError::invalid("every data source needs an id and a name"));
|
||||
}
|
||||
}
|
||||
let path = project_dir::path(root, FILE);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::fs::write(path, text)?;
|
||||
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
|
||||
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
|
||||
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
|
||||
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
|
||||
table.remove("global");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -496,7 +552,11 @@ mod tests {
|
||||
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("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
assert!(parse_url("oracle://x").is_err());
|
||||
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Oracle));
|
||||
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(1521), "ORCLPDB1"));
|
||||
assert_eq!((u.user.as_str(), u.password.as_deref()), ("scott", Some("tiger")));
|
||||
assert_eq!(parse_url("oracledb://[email protected]/hrdb").unwrap().driver, Some(DbDriver::Oracle));
|
||||
assert!(parse_url("postgres://h:99999/db").is_err());
|
||||
}
|
||||
|
||||
@@ -519,4 +579,31 @@ mod tests {
|
||||
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
|
||||
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_urls_resolve_http_https_and_credentials() {
|
||||
assert_eq!(DbDriver::Clickhouse.default_port(), Some(8123));
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Clickhouse,
|
||||
url_only: true,
|
||||
url: "https://reader:s%[email protected]:8443/analytics?max_execution_time=30".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = source.resolved().unwrap();
|
||||
assert_eq!(resolved.host, "db.example");
|
||||
assert_eq!(resolved.port, Some(8443));
|
||||
assert_eq!(resolved.database, "analytics");
|
||||
assert_eq!(resolved.user, "reader");
|
||||
assert_eq!(resolved.ssl, DbSslMode::VerifyFull);
|
||||
assert_eq!(source.url_password().as_deref(), Some("s@cret"));
|
||||
assert_eq!(resolved.properties[0].name, "max_execution_time");
|
||||
let http = DataSource { url: "http://localhost/default".into(), ..source.clone() }.resolved().unwrap();
|
||||
assert_eq!(http.ssl, DbSslMode::Disable);
|
||||
let jdbc = DataSource { url: "jdbc:clickhouse://localhost/analytics".into(), ..source.clone() };
|
||||
assert_eq!(jdbc.resolved().unwrap().database, "analytics");
|
||||
let secure = DataSource { url: "https://db.example/default?sslmode=disable".into(), ..source };
|
||||
assert_eq!(secure.resolved().unwrap().ssl, DbSslMode::VerifyFull);
|
||||
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
|
||||
assert!(!error.message.contains("secret"));
|
||||
}
|
||||
}
|
||||
+212
-11
@@ -9,7 +9,8 @@ use specta::Type;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlStatement {
|
||||
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded).
|
||||
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except
|
||||
/// after an Oracle PL/SQL block, whose `END;` needs it).
|
||||
pub from: u32,
|
||||
pub to: u32,
|
||||
pub text: String,
|
||||
@@ -28,6 +29,9 @@ enum State {
|
||||
/// Every statement of `text` that contains more than whitespace and comments.
|
||||
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let mysql = driver == DbDriver::Mysql;
|
||||
let clickhouse = driver == DbDriver::Clickhouse;
|
||||
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
|
||||
let plsql = driver == DbDriver::Oracle;
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
// UTF-16 offset of each char index (plus the end).
|
||||
let mut u16_at = Vec::with_capacity(chars.len() + 1);
|
||||
@@ -45,6 +49,14 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
// Whether the current segment has anything but whitespace / comments.
|
||||
let mut has_code = false;
|
||||
let mut i = 0usize;
|
||||
// PL/SQL block depth (BEGIN / CASE / LOOP open, END closes).
|
||||
let mut depth = 0usize;
|
||||
// Inside a top-level block's declaration section (`DECLARE … BEGIN`).
|
||||
let mut declarations = false;
|
||||
// The segment is a PL/SQL block: its closing `;` is part of the statement.
|
||||
let mut block = false;
|
||||
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
|
||||
let mut package = false;
|
||||
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
|
||||
if !has_code {
|
||||
return;
|
||||
@@ -68,11 +80,24 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let next = chars.get(i + 1).map(|c| c.1);
|
||||
match state {
|
||||
State::Code => match c {
|
||||
';' => {
|
||||
push(&mut out, start, i, has_code);
|
||||
_ if plsql && !has_code && c.is_alphabetic() => {
|
||||
match plsql_unit(&chars, i) {
|
||||
Unit::Sql => {}
|
||||
Unit::Block => block = true,
|
||||
Unit::Subprogram => (block, declarations) = (true, true),
|
||||
Unit::Package => (block, package) = (true, true),
|
||||
}
|
||||
has_code = true;
|
||||
// Re-read this character as code (it may start BEGIN / DECLARE).
|
||||
continue;
|
||||
}
|
||||
';' if depth == 0 && !declarations && !package => {
|
||||
push(&mut out, start, if block { i + 1 } else { i }, has_code);
|
||||
start = i + 1;
|
||||
has_code = false;
|
||||
block = false;
|
||||
}
|
||||
';' => has_code = true,
|
||||
'\'' => {
|
||||
state = State::Single;
|
||||
has_code = true;
|
||||
@@ -81,19 +106,32 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
state = State::Double;
|
||||
has_code = true;
|
||||
}
|
||||
'`' if mysql => {
|
||||
'`' if mysql || clickhouse => {
|
||||
state = State::Backtick;
|
||||
has_code = true;
|
||||
}
|
||||
'/' if next == Some('*') => {
|
||||
state = State::BlockComment;
|
||||
i += 1;
|
||||
}
|
||||
'/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => {
|
||||
// SQL*Plus block terminator: `/` alone on its line ends the statement.
|
||||
push(&mut out, start, i, has_code);
|
||||
while chars.get(i).is_some_and(|&(_, c)| c != '\n') {
|
||||
i += 1;
|
||||
}
|
||||
start = i + 1;
|
||||
has_code = false;
|
||||
depth = 0;
|
||||
declarations = false;
|
||||
block = false;
|
||||
package = false;
|
||||
}
|
||||
'-' if next == Some('-') => {
|
||||
state = State::LineComment;
|
||||
i += 1;
|
||||
}
|
||||
'#' if mysql => state = State::LineComment,
|
||||
'/' if next == Some('*') => {
|
||||
state = State::BlockComment;
|
||||
i += 1;
|
||||
}
|
||||
'$' if driver == DbDriver::Postgres => {
|
||||
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
|
||||
let rest = &text[byte_at(i)..];
|
||||
@@ -113,11 +151,39 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "BEGIN") => {
|
||||
depth += 1;
|
||||
declarations = false;
|
||||
has_code = true;
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => {
|
||||
depth += 1;
|
||||
has_code = true;
|
||||
}
|
||||
// `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block
|
||||
// end at BEGIN, so their `;` must not split it.
|
||||
_ if plsql && word_at(&chars, i, "DECLARE") => {
|
||||
if depth == 0 {
|
||||
declarations = true;
|
||||
}
|
||||
has_code = true;
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "END") => {
|
||||
// `END IF` closes an IF (which never opened a block); END LOOP / END CASE
|
||||
// close the block their keyword opened. Other ENDs close a block.
|
||||
let after = skip_ws(&chars, i + 3);
|
||||
let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w));
|
||||
if closing != Some("IF") && depth > 0 {
|
||||
depth -= 1;
|
||||
}
|
||||
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
|
||||
has_code = true;
|
||||
}
|
||||
c if !c.is_whitespace() => has_code = true,
|
||||
_ => {}
|
||||
},
|
||||
State::Single => {
|
||||
if mysql && c == '\\' {
|
||||
if (mysql || clickhouse) && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '\'' {
|
||||
if next == Some('\'') {
|
||||
@@ -128,7 +194,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
}
|
||||
}
|
||||
State::Double => {
|
||||
if c == '"' {
|
||||
if clickhouse && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '"' {
|
||||
if next == Some('"') {
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -137,7 +205,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
}
|
||||
}
|
||||
State::Backtick => {
|
||||
if c == '`' {
|
||||
if clickhouse && (c == '\\' || (c == '`' && next == Some('`'))) {
|
||||
i += 1;
|
||||
} else if c == '`' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +229,73 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Unit {
|
||||
Sql,
|
||||
/// `DECLARE` / `BEGIN`: an anonymous block.
|
||||
Block,
|
||||
/// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN.
|
||||
Subprogram,
|
||||
/// `CREATE PACKAGE [BODY]` / `TYPE BODY`.
|
||||
Package,
|
||||
}
|
||||
|
||||
/// What kind of PL/SQL unit, if any, a statement starting at `i` is.
|
||||
fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit {
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
while words.len() < 6 {
|
||||
let word: String = chars[i.min(chars.len())..]
|
||||
.iter()
|
||||
.map(|&(_, c)| c)
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
|
||||
.collect();
|
||||
if word.is_empty() {
|
||||
break;
|
||||
}
|
||||
i = skip_ws(chars, i + word.chars().count());
|
||||
words.push(word.to_ascii_uppercase());
|
||||
}
|
||||
let w = |n: usize| words.get(n).map_or("", String::as_str);
|
||||
match w(0) {
|
||||
"DECLARE" | "BEGIN" => return Unit::Block,
|
||||
"CREATE" => {}
|
||||
_ => return Unit::Sql,
|
||||
}
|
||||
let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 };
|
||||
if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") {
|
||||
n += 1;
|
||||
}
|
||||
match (w(n), w(n + 1)) {
|
||||
("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram,
|
||||
("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package,
|
||||
_ => Unit::Sql,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the characters at `i` are the keyword `word` (identifier bounds respected).
|
||||
fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
|
||||
let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c));
|
||||
before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$')
|
||||
&& after.is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars())
|
||||
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
|
||||
}
|
||||
|
||||
/// Whether only whitespace follows `i` until the end of its line.
|
||||
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
|
||||
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
|
||||
}
|
||||
|
||||
/// Whether only whitespace precedes `i` since the start of its line.
|
||||
fn starts_line(chars: &[(usize, char)], i: usize) -> bool {
|
||||
chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
|
||||
}
|
||||
|
||||
/// The index of the first non-whitespace character at or after `i`.
|
||||
fn skip_ws(chars: &[(usize, char)], i: usize) -> usize {
|
||||
chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n)
|
||||
}
|
||||
|
||||
/// The statement to run for a caret at `offset` (UTF-16): the one containing
|
||||
/// it, else the nearest one before it (caret after `;` or on a blank line
|
||||
/// below), else the next one.
|
||||
@@ -224,6 +361,12 @@ mod tests {
|
||||
assert_eq!(texts(sql, DbDriver::Mysql), vec!["select `a;b`, 'it\\'s;' from t # x;y", "select 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_backticks_and_escaped_quotes_keep_statements_intact() {
|
||||
let query = r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'; SELECT 2"#;
|
||||
assert_eq!(texts(query, DbDriver::Clickhouse), vec![r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'"#, "SELECT 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_dollar_quotes() {
|
||||
let sql =
|
||||
@@ -266,4 +409,62 @@ mod tests {
|
||||
assert_eq!(s.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), vec!["select 1", "select 2"]);
|
||||
assert_eq!((s[1].from, s[1].to), (13, 21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_keeps_plsql_blocks_as_one_statement() {
|
||||
let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend";
|
||||
assert_eq!(
|
||||
texts(&format!("{block};\nselect 2;"), DbDriver::Oracle),
|
||||
vec![format!("{block};"), "select 2".into()]
|
||||
);
|
||||
let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend";
|
||||
assert_eq!(
|
||||
texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle),
|
||||
vec![format!("{nested};"), "select 3".into()]
|
||||
);
|
||||
// Plain SQL with CASE is unaffected; END IF does not close a block twice.
|
||||
assert_eq!(
|
||||
texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle),
|
||||
vec!["select case when a = 1 then 2 end from t", "select 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_slash_terminator_and_other_drivers_unaffected() {
|
||||
// `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing.
|
||||
assert_eq!(
|
||||
texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle),
|
||||
vec!["begin\n null;\nend;", "select 2"]
|
||||
);
|
||||
// A division is not a terminator.
|
||||
assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]);
|
||||
assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_create_units_keep_their_declarations_and_end() {
|
||||
let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;";
|
||||
assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]);
|
||||
let trigger =
|
||||
"CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;";
|
||||
assert_eq!(
|
||||
texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle),
|
||||
vec![trigger, "select 2 from dual"]
|
||||
);
|
||||
// Packages end at `/` (or the end of the script), like in SQL*Plus.
|
||||
let spec = "create package k as\n procedure a;\n function f return number;\nend k;";
|
||||
let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;";
|
||||
assert_eq!(
|
||||
texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle),
|
||||
vec![spec, body, "select 3 from dual"]
|
||||
);
|
||||
// Other CREATE statements and object types stay plain SQL.
|
||||
assert_eq!(
|
||||
texts(
|
||||
"create view v as select 1 x from dual; create type t as object (a number); select 4",
|
||||
DbDriver::Oracle
|
||||
),
|
||||
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
|
||||
);
|
||||
}
|
||||
}
|
||||
+288
-7
@@ -7,6 +7,7 @@ use specta::Type;
|
||||
|
||||
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!("`{}`", name.replace('\\', "\\\\").replace('`', "\\`")),
|
||||
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
|
||||
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
@@ -15,7 +16,7 @@ pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
|
||||
pub fn quote_literal(driver: DbDriver, value: &str) -> String {
|
||||
let mut s = value.replace('\'', "''");
|
||||
if driver == DbDriver::Mysql {
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
|
||||
s = s.replace('\\', "\\\\");
|
||||
}
|
||||
format!("'{s}'")
|
||||
@@ -45,15 +46,42 @@ pub fn databases(templates: bool) -> &'static str {
|
||||
/// Databases of managed services that their users cannot use (hidden unless connected to).
|
||||
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
|
||||
|
||||
/// Oracle system accounts hidden unless `show_system` (see the Schemas tab).
|
||||
pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
|
||||
"CTXSYS",
|
||||
"DBSFWUSER",
|
||||
"DVSYS",
|
||||
"GSMADMIN_INTERNAL",
|
||||
"LBACSYS",
|
||||
"MDDATA",
|
||||
"MDSYS",
|
||||
"OLAPSYS",
|
||||
"OUTLN",
|
||||
"SYS",
|
||||
"SYSTEM",
|
||||
"XDB",
|
||||
"XS$NULL",
|
||||
];
|
||||
|
||||
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",
|
||||
DbDriver::Clickhouse if system => "SELECT name FROM system.databases ORDER BY name",
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
|
||||
}
|
||||
DbDriver::Postgres if system => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username",
|
||||
DbDriver::Oracle => {
|
||||
"SELECT username FROM all_users WHERE username NOT IN \
|
||||
('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', \
|
||||
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
@@ -73,11 +101,23 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
|
||||
pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT name, CASE engine WHEN 'View' THEN 'view' WHEN 'MaterializedView' THEN 'materialized view' \
|
||||
WHEN 'WindowView' THEN 'view' ELSE 'table' END FROM system.tables \
|
||||
WHERE database = {} AND is_temporary = 0 ORDER BY name",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, type FROM {}.sqlite_master WHERE type IN ('table', 'view') \
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT table_name, 'table' FROM all_tables WHERE owner = {} \
|
||||
UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1",
|
||||
quote_literal(driver, schema),
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Postgres => format!(
|
||||
"SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||
@@ -101,6 +141,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT name, type, CASE WHEN startsWith(type, 'Nullable(') \
|
||||
OR startsWith(type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, \
|
||||
if(default_kind = '', NULL, default_expression), is_in_primary_key \
|
||||
FROM system.columns WHERE database = {s} AND table = {t} ORDER BY position"
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END, dflt_value, \
|
||||
CASE WHEN pk > 0 THEN 1 ELSE 0 END FROM pragma_table_info({t}, {s}) ORDER BY cid"
|
||||
@@ -131,6 +177,17 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
|
||||
ORDER BY c.ordinal_position"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \
|
||||
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM all_tab_columns c \
|
||||
LEFT JOIN (SELECT kc.column_name FROM all_constraints con \
|
||||
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
|
||||
AND kc.constraint_name = con.constraint_name \
|
||||
WHERE con.constraint_type = 'P' AND con.owner = {s} AND con.table_name = {t}) k \
|
||||
ON k.column_name = c.column_name \
|
||||
WHERE c.owner = {s} AND c.table_name = {t} ORDER BY c.column_id"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +195,12 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT * FROM (SELECT 'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression \
|
||||
FROM system.tables WHERE database = {s} AND name = {t} AND primary_key != '' \
|
||||
UNION ALL SELECT name, 0, expr FROM system.data_skipping_indices \
|
||||
WHERE database = {s} AND table = {t}) ORDER BY index_name"
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT il.name, il.\"unique\", ii.name FROM pragma_index_list({t}, {s}) il \
|
||||
JOIN pragma_index_info(il.name, {s}) ii ORDER BY il.name, ii.seqno"
|
||||
@@ -162,6 +225,12 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
|
||||
ORDER BY i.name, ic.key_ordinal"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \
|
||||
FROM all_indexes i JOIN all_ind_columns ic \
|
||||
ON ic.index_owner = i.owner AND ic.index_name = i.index_name \
|
||||
WHERE i.owner = {s} AND i.table_name = {t} ORDER BY i.index_name, ic.column_position"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +238,15 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
/// for SQL completion, grouped by table in column order.
|
||||
pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT c.database, c.table, CASE t.engine WHEN 'View' THEN 'view' \
|
||||
WHEN 'MaterializedView' THEN 'materialized view' WHEN 'WindowView' THEN 'view' ELSE 'table' END, \
|
||||
c.name, c.type, CASE WHEN startsWith(c.type, 'Nullable(') \
|
||||
OR startsWith(c.type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, c.is_in_primary_key, 1 \
|
||||
FROM system.columns c JOIN system.tables t ON t.database = c.database AND t.name = c.table \
|
||||
WHERE lower(c.database) NOT IN ('system', 'information_schema') AND t.is_temporary = 0 \
|
||||
ORDER BY c.database, c.table, c.position LIMIT 50000"
|
||||
}
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, m.type, p.name, p.type, CASE WHEN p.\"notnull\" = 0 THEN 'YES' ELSE 'NO' END, \
|
||||
CASE WHEN p.pk > 0 THEN 1 ELSE 0 END, \
|
||||
@@ -213,6 +291,21 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
"SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \
|
||||
c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \
|
||||
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END, \
|
||||
CASE WHEN c.data_default IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM all_tab_columns c \
|
||||
LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name \
|
||||
LEFT JOIN (SELECT con.owner, con.table_name, kc.column_name FROM all_constraints con \
|
||||
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
|
||||
AND kc.constraint_name = con.constraint_name WHERE con.constraint_type = 'P') k \
|
||||
ON k.owner = c.owner AND k.table_name = c.table_name AND k.column_name = c.column_name \
|
||||
WHERE c.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', \
|
||||
'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
|
||||
ORDER BY c.owner, c.table_name, c.column_id FETCH FIRST 50000 ROWS ONLY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +314,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
/// when the key references the primary key implicitly.
|
||||
pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \
|
||||
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \
|
||||
@@ -251,6 +345,19 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
AND table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, \
|
||||
rcon.owner, rcon.table_name, rcol.column_name \
|
||||
FROM all_constraints con \
|
||||
JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name \
|
||||
AND col.constraint_name = con.constraint_name \
|
||||
JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name \
|
||||
JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name \
|
||||
AND rcol.constraint_name = rcon.constraint_name \
|
||||
WHERE con.constraint_type = 'R' AND con.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', \
|
||||
'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
|
||||
ORDER BY 1, 2, 3, col.position FETCH FIRST 20000 ROWS ONLY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +365,7 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => foreign_keys(driver).to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq"
|
||||
),
|
||||
@@ -270,16 +378,21 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, rcon.owner, rcon.table_name, rcol.column_name FROM all_constraints con JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name AND col.constraint_name = con.constraint_name JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name AND rcol.constraint_name = rcon.constraint_name WHERE con.constraint_type = 'R' AND con.owner = {s} AND con.table_name = {t} ORDER BY con.constraint_name, col.position"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema unqualified names resolve to (`None`: always `main`).
|
||||
pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => Some("SELECT currentDatabase()"),
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Postgres => Some("SELECT current_schema()"),
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
|
||||
DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +437,8 @@ pub fn table_page(
|
||||
sql.push_str(" ORDER BY (SELECT 1)");
|
||||
}
|
||||
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
|
||||
} else if driver == DbDriver::Oracle {
|
||||
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH FIRST {} ROWS ONLY", limit + 1));
|
||||
} else {
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
|
||||
}
|
||||
@@ -362,6 +477,9 @@ fn where_key(driver: DbDriver, key: &[CellValue]) -> String {
|
||||
/// The DML statement for `edit`. Values are written as quoted literals, which
|
||||
/// every supported database coerces to the column type.
|
||||
pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowEdit) -> Result<String, String> {
|
||||
if driver == DbDriver::Clickhouse {
|
||||
return Err("ClickHouse table editing is unsupported: primary keys do not uniquely identify rows and edits require transactions".into());
|
||||
}
|
||||
let target = qualified(driver, schema, table);
|
||||
match edit {
|
||||
DbRowEdit::Update { key, values } => {
|
||||
@@ -383,6 +501,8 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE
|
||||
if values.is_empty() {
|
||||
return Ok(match driver {
|
||||
DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"),
|
||||
// Oracle has no DEFAULT VALUES; without columns there is nothing to insert.
|
||||
DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()),
|
||||
_ => format!("INSERT INTO {target} DEFAULT VALUES"),
|
||||
});
|
||||
}
|
||||
@@ -450,6 +570,7 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
|
||||
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '' WHERE 0".to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
@@ -470,6 +591,12 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
|
||||
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT trigger_name, CASE WHEN trigger_type LIKE 'BEFORE%' THEN 'before' \
|
||||
WHEN trigger_type LIKE 'INSTEAD%' THEN 'instead of' ELSE 'after' END, \
|
||||
lower(replace(triggering_event, ' OR ', ',')) \
|
||||
FROM all_triggers WHERE owner = {s} AND table_name = {t} ORDER BY 1"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,7 +619,7 @@ pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
|
||||
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
let s = quote_literal(driver, schema);
|
||||
match driver {
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Sqlite | DbDriver::Clickhouse => None,
|
||||
// Functions of extensions are left out, as in IDEA.
|
||||
DbDriver::Postgres => Some(format!(
|
||||
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
|
||||
@@ -512,17 +639,28 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \
|
||||
FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL).
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
(driver == DbDriver::Postgres).then(|| {
|
||||
format!(
|
||||
match driver {
|
||||
DbDriver::Postgres => Some(format!(
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)
|
||||
})
|
||||
)),
|
||||
// Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c.
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The statement reading the definition of a routine (a row per overload), trigger
|
||||
@@ -567,6 +705,26 @@ pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, nam
|
||||
(DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2),
|
||||
(DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2),
|
||||
(DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2),
|
||||
(DbDriver::Oracle, K::Function | K::Procedure | K::Trigger) => (
|
||||
format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL",
|
||||
match kind {
|
||||
K::Function => "FUNCTION",
|
||||
K::Procedure => "PROCEDURE",
|
||||
_ => "TRIGGER",
|
||||
}
|
||||
),
|
||||
0,
|
||||
),
|
||||
(DbDriver::Oracle, K::Sequence) => (
|
||||
format!(
|
||||
"SELECT 'create sequence ' || {n} || E'\\n increment by ' || increment_by || \
|
||||
E'\\n minvalue ' || min_value || E'\\n maxvalue ' || max_value || \
|
||||
E'\\n start with ' || last_number || CASE WHEN cycle_flag = 'Y' THEN E'\\n cycle' ELSE '' END \
|
||||
FROM all_sequences WHERE sequence_owner = {s} AND sequence_name = {n}"
|
||||
),
|
||||
0,
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -625,6 +783,71 @@ mod tests {
|
||||
assert_eq!(quote_literal(DbDriver::Mysql, "it's \\"), "'it''s \\\\'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_escapes_backslashes_and_delimiters() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(quote_literal(driver, r"path\O'Brien"), r"'path\\O''Brien'");
|
||||
assert_eq!(quote_literal(driver, r"\' OR 1 = 1 --"), r"'\\'' OR 1 = 1 --'");
|
||||
assert_eq!(quote_ident(driver, r"a\`b"), r"`a\\\`b`");
|
||||
assert_eq!(qualified(driver, "data base", "event`log"), r"`data base`.`event\`log`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_metadata_preserves_types_defaults_and_nonunique_keys() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(schemas(driver, true), "SELECT name FROM system.databases ORDER BY name");
|
||||
assert!(schemas(driver, false).contains("lower(name) NOT IN ('system', 'information_schema')"));
|
||||
assert_eq!(current_schema(driver), Some("SELECT currentDatabase()"));
|
||||
let relations = tables(driver, "event's\\db");
|
||||
assert!(relations.contains("WHEN 'MaterializedView' THEN 'materialized view'"));
|
||||
assert!(relations.contains(r"WHERE database = 'event''s\\db' AND is_temporary = 0"));
|
||||
let fields = columns(driver, "default", "events");
|
||||
assert!(fields.starts_with("SELECT name, type, CASE WHEN startsWith(type, 'Nullable(')"));
|
||||
assert!(fields.contains("startsWith(type, 'LowCardinality(Nullable(')"));
|
||||
assert!(fields.contains("if(default_kind = '', NULL, default_expression), is_in_primary_key"));
|
||||
assert!(fields.ends_with("WHERE database = 'default' AND table = 'events' ORDER BY position"));
|
||||
let keys = indexes(driver, "default", "events");
|
||||
assert!(keys.contains("'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression"));
|
||||
assert!(keys.contains("SELECT name, 0, expr FROM system.data_skipping_indices"));
|
||||
let completion = completion_columns(driver);
|
||||
assert!(completion.contains("c.is_in_primary_key, 1"));
|
||||
assert!(completion.contains("ORDER BY c.database, c.table, c.position LIMIT 50000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_has_no_foreign_key_trigger_or_schema_routine_catalogs() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(foreign_keys(driver), "SELECT '', '', '', '', '', '', '' WHERE 0");
|
||||
assert_eq!(table_foreign_keys(driver, "default", "events"), foreign_keys(driver));
|
||||
assert_eq!(triggers(driver, "default", "events"), "SELECT '', '', '' WHERE 0");
|
||||
assert!(routines(driver, "default").is_none());
|
||||
assert!(sequences(driver, "default").is_none());
|
||||
assert!(object_ddl(driver, "default", crate::DbObjectKind::Trigger, "t").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_pages_quote_keys_and_fetch_one_extra_row() {
|
||||
let sort = [SortKey { column: "event`id".into(), descending: true }];
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Clickhouse, "default", "events", Some("WHERE id > 3"), &sort, 50, 25),
|
||||
r"SELECT * FROM `default`.`events` WHERE id > 3 ORDER BY `event\`id` DESC LIMIT 26 OFFSET 50"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_keys_never_enable_transactional_table_edits() {
|
||||
let key = vec![cv("id", Some("1"))];
|
||||
for edit in [
|
||||
DbRowEdit::Update { key: key.clone(), values: vec![cv("name", Some("new"))] },
|
||||
DbRowEdit::Delete { key },
|
||||
DbRowEdit::Insert { values: vec![cv("name", Some("new"))] },
|
||||
DbRowEdit::Insert { values: vec![] },
|
||||
] {
|
||||
let error = edit_statement(DbDriver::Clickhouse, "default", "events", &edit).unwrap_err();
|
||||
assert!(error.contains("primary keys do not uniquely identify rows"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages() {
|
||||
let sort = [SortKey { column: "id".into(), descending: true }];
|
||||
@@ -688,6 +911,64 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_quoting_pages_and_current_schema() {
|
||||
assert_eq!(quote_ident(DbDriver::Oracle, "a\"b"), "\"a\"\"b\"");
|
||||
assert_eq!(quote_literal(DbDriver::Oracle, "it's"), "'it''s'");
|
||||
assert_eq!(qualified(DbDriver::Oracle, "hr", "employees"), "\"hr\".\"employees\"");
|
||||
assert_eq!(current_schema(DbDriver::Oracle), Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"));
|
||||
assert_eq!(crate::conn::begin_statement(DbDriver::Oracle), "");
|
||||
let sort = [SortKey { column: "id".into(), descending: true }];
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Oracle, "hr", "employees", Some("WHERE id > 3"), &sort, 50, 25),
|
||||
"SELECT * FROM \"hr\".\"employees\" WHERE id > 3 ORDER BY \"id\" DESC OFFSET 50 ROWS FETCH FIRST 26 ROWS ONLY"
|
||||
);
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Oracle, "", "t", None, &[], 0, 10),
|
||||
"SELECT * FROM \"t\" OFFSET 0 ROWS FETCH FIRST 11 ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_introspection_queries() {
|
||||
assert_eq!(DbDriver::Oracle.default_port(), Some(1521));
|
||||
assert!(schemas(DbDriver::Oracle, true).contains("all_users"));
|
||||
assert!(schemas(DbDriver::Oracle, false).contains("'MDSYS'"));
|
||||
assert!(tables(DbDriver::Oracle, "hr").contains("all_tables"));
|
||||
assert!(tables(DbDriver::Oracle, "hr").contains("all_views"));
|
||||
let cols = columns(DbDriver::Oracle, "hr", "employees");
|
||||
assert!(cols.contains("all_tab_columns"));
|
||||
assert!(cols.contains("DECODE(c.nullable, 'Y', 'YES', 'NO')"));
|
||||
assert!(cols.contains("constraint_type = 'P'"));
|
||||
assert!(indexes(DbDriver::Oracle, "hr", "t").contains("all_indexes"));
|
||||
assert!(foreign_keys(DbDriver::Oracle).contains("constraint_type = 'R'"));
|
||||
assert!(table_foreign_keys(DbDriver::Oracle, "hr", "t").contains("all_cons_columns"));
|
||||
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!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_edits_and_object_ddl() {
|
||||
let key = vec![cv("id", Some("1"))];
|
||||
assert_eq!(
|
||||
edit_statement(
|
||||
DbDriver::Oracle,
|
||||
"hr",
|
||||
"t",
|
||||
&DbRowEdit::Update { key: key.clone(), values: vec![cv("n", Some("x"))] }
|
||||
)
|
||||
.unwrap(),
|
||||
"UPDATE \"hr\".\"t\" SET \"n\" = 'x' WHERE \"id\" = '1'"
|
||||
);
|
||||
assert!(edit_statement(DbDriver::Oracle, "hr", "t", &DbRowEdit::Insert { values: vec![] }).is_err());
|
||||
use crate::DbObjectKind as K;
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_trigger_events() {
|
||||
let ev = sqlite_trigger_event;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbSslMode, SortKey, sql};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn check(condition: bool, message: &str) -> IdeResult<()> {
|
||||
if condition { Ok(()) } else { Err(IdeError::invalid(message)) }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
|
||||
async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = 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);
|
||||
let mut source = DataSource {
|
||||
id: "clickhouse".into(),
|
||||
name: "clickhouse".into(),
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("set CLICKHOUSE_TEST_HOST"),
|
||||
port: Some(
|
||||
std::env::var("CLICKHOUSE_TEST_PORT")
|
||||
.map(|value| value.parse().expect("numeric CLICKHOUSE_TEST_PORT"))
|
||||
.unwrap_or(8443),
|
||||
),
|
||||
user: std::env::var("CLICKHOUSE_TEST_USER").expect("set CLICKHOUSE_TEST_USER"),
|
||||
database: "default".into(),
|
||||
ssl: DbSslMode::VerifyFull,
|
||||
..Default::default()
|
||||
};
|
||||
source.introspection.show_system = true;
|
||||
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("set CLICKHOUSE_TEST_PASSWORD");
|
||||
check(!db.test(&source, Some(password.clone()), None).await?.is_empty(), "missing server version")?;
|
||||
db.save_sources(vec![source]).await?;
|
||||
db.set_password("clickhouse", Some(password), false).await?;
|
||||
let cancel = CancellationToken::new();
|
||||
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let name = format!("manager_test_{}_{suffix}", std::process::id());
|
||||
let table = sql::quote_ident(DbDriver::Clickhouse, &name);
|
||||
db.execute(
|
||||
"clickhouse",
|
||||
&format!(
|
||||
"CREATE TEMPORARY TABLE {table} (id UInt64, label Nullable(String) DEFAULT NULL, \
|
||||
category LowCardinality(Nullable(String)) DEFAULT NULL, items Array(Nullable(UInt8)), \
|
||||
label_size UInt64 ALIAS length(ifNull(label, ''))) ENGINE = Memory"
|
||||
),
|
||||
0,
|
||||
&cancel,
|
||||
)
|
||||
.await?;
|
||||
let checked: IdeResult<()> = async {
|
||||
db.execute(
|
||||
"clickhouse",
|
||||
&format!("INSERT INTO {table} (id, label, items) VALUES (1, 'first', [1, NULL]), (1, NULL, [])"),
|
||||
0,
|
||||
&cancel,
|
||||
)
|
||||
.await?;
|
||||
let schemas = db.schemas("clickhouse").await?;
|
||||
check(schemas.iter().any(|schema| schema == "default"), "default database missing")?;
|
||||
check(schemas.iter().any(|schema| schema == "system"), "system database missing")?;
|
||||
let tables = db.tables("clickhouse", "system").await?;
|
||||
check(tables.iter().any(|table| table.name == "one"), "system table listing missing one")?;
|
||||
let details = db.table_details("clickhouse", "", &name).await?;
|
||||
check(details.columns.len() == 5, "temporary table columns missing")?;
|
||||
for (name, data_type, nullable) in [
|
||||
("id", "UInt64", false),
|
||||
("label", "Nullable(String)", true),
|
||||
("category", "LowCardinality(Nullable(String))", true),
|
||||
("items", "Array(Nullable(UInt8))", false),
|
||||
] {
|
||||
check(
|
||||
details
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.name == name && column.data_type == data_type && column.nullable == nullable),
|
||||
"column type or nullability mismatch",
|
||||
)?;
|
||||
}
|
||||
check(
|
||||
details.columns.iter().any(|column| column.name == "label" && column.default.as_deref() == Some("NULL")),
|
||||
"explicit default expression missing",
|
||||
)?;
|
||||
check(details.indexes.iter().all(|index| !index.unique), "ClickHouse index reported unique")?;
|
||||
check(details.foreign_keys.is_empty() && details.triggers.is_empty(), "unsupported table objects returned")?;
|
||||
let sort = [SortKey { column: "id".into(), descending: false }];
|
||||
let first = db.table_page("clickhouse", "", &name, None, &sort, 0, 1, &cancel).await?;
|
||||
check(first.result.rows.len() == 1 && first.result.has_more, "first page limit failed")?;
|
||||
let second = db.table_page("clickhouse", "", &name, None, &sort, 1, 1, &cancel).await?;
|
||||
check(second.result.rows.len() == 1 && !second.result.has_more, "page offset failed")?;
|
||||
let ddl = db.ddl("clickhouse", "", &name).await?;
|
||||
check(ddl.contains(&name) && ddl.contains("Memory"), "temporary table DDL missing")?;
|
||||
let completion = db.completion("clickhouse").await?;
|
||||
check(completion.default_schema == "default", "completion default database mismatch")?;
|
||||
check(completion.foreign_keys.is_empty(), "completion returned unsupported foreign keys")?;
|
||||
if let Some(table) = completion.tables.first() {
|
||||
let details = db.table_details("clickhouse", &table.schema, &table.name).await?;
|
||||
check(
|
||||
table.columns.iter().all(|column| {
|
||||
details.columns.iter().any(|field| field.name == column.name && field.data_type == column.data_type)
|
||||
}),
|
||||
"completion columns differ from catalog",
|
||||
)?;
|
||||
}
|
||||
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
|
||||
.execute("clickhouse", &format!("SELECT {}", sql::quote_literal(DbDriver::Clickhouse, value)), 1, &cancel)
|
||||
.await?;
|
||||
check(result.rows == vec![vec![Some(value.to_owned())]], "quoted literal changed value")?;
|
||||
let edits = [DbRowEdit::Update {
|
||||
key: vec![CellValue { column: "id".into(), value: Some("1".into()) }],
|
||||
values: vec![CellValue { column: "label".into(), value: Some("changed".into()) }],
|
||||
}];
|
||||
check(
|
||||
db.apply_edits("clickhouse", "", &name, &edits)
|
||||
.await
|
||||
.err()
|
||||
.is_some_and(|error| error.message.contains("table editing is unsupported")),
|
||||
"supplied row key authorized table editing",
|
||||
)?;
|
||||
check(db.set_manual_commit("clickhouse", true).await.is_err(), "manual transactions were accepted")?;
|
||||
for commit in [false, true] {
|
||||
check(db.finish_transaction("clickhouse", commit).await.is_err(), "transaction control was accepted")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let cleanup = db.execute("clickhouse", &format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
|
||||
db.disconnect("clickhouse").await;
|
||||
checked?;
|
||||
cleanup?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Introspection, queries and table edits against a real SQLite file.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
|
||||
use ide_db::{
|
||||
CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, QueryResult, SortKey,
|
||||
};
|
||||
|
||||
async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -123,6 +125,28 @@ async fn queries_with_limits_nulls_and_blobs() -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_later_page_returns_only_its_rows() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
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).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).await?;
|
||||
assert_eq!(ids(&last), vec!["3"]);
|
||||
assert!(!last.has_more);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_sorts_filters_and_edits_in_a_transaction() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
@@ -197,7 +221,7 @@ async fn count(db: &DbManager) -> String {
|
||||
async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
db.set_manual_commit("s1", true).await;
|
||||
db.set_manual_commit("s1", true).await.unwrap();
|
||||
db.execute("s1", "INSERT INTO users (name) VALUES ('dee')", 1, &c).await?;
|
||||
// The session sees its own change.
|
||||
assert_eq!(count(&db).await, "4");
|
||||
@@ -208,10 +232,10 @@ async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResu
|
||||
db.finish_transaction("s1", true).await?;
|
||||
// Leaving manual mode rolls back what is still open.
|
||||
db.execute("s1", "DELETE FROM users", 1, &c).await?;
|
||||
db.set_manual_commit("s1", false).await;
|
||||
db.set_manual_commit("s1", false).await.unwrap();
|
||||
assert_eq!(count(&db).await, "4");
|
||||
// Disconnecting with an open transaction does not hang.
|
||||
db.set_manual_commit("s1", true).await;
|
||||
db.set_manual_commit("s1", true).await.unwrap();
|
||||
db.execute("s1", "DELETE FROM users", 1, &c).await?;
|
||||
db.disconnect("s1").await;
|
||||
assert_eq!(count(&db).await, "4");
|
||||
|
||||
@@ -440,3 +440,89 @@ pub fn compose_op(
|
||||
});
|
||||
run_streaming(ep, None, &args, cancel, on_output)
|
||||
}
|
||||
|
||||
/// 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_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(),
|
||||
DockerEndpoint::Tcp { host, .. } => {
|
||||
let host = host.trim();
|
||||
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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}}"];
|
||||
args.extend(name);
|
||||
Ok(run(ep, &args, QUICK)?.trim().to_owned())
|
||||
}
|
||||
|
||||
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
|
||||
/// this machine; `ssh://` and other hosts are not.
|
||||
pub fn daemon_url_is_local(url: &str) -> bool {
|
||||
let url = url.trim();
|
||||
let Some((scheme, rest)) = url.split_once("://") else { return true };
|
||||
match scheme {
|
||||
"unix" | "npipe" => true,
|
||||
"tcp" | "http" | "https" => {
|
||||
let host = rest.split('/').next().unwrap_or_default();
|
||||
let host = match host.strip_prefix('[') {
|
||||
Some(v6) => v6.split(']').next().unwrap_or_default(),
|
||||
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
|
||||
};
|
||||
matches!(host, "localhost" | "127.0.0.1" | "::1")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `docker compose config --format json` of the project at `dir` (`files`
|
||||
/// relative to it; none = the project's default file).
|
||||
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
|
||||
let mut args: Vec<&str> = vec!["compose"];
|
||||
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
|
||||
args.extend(["--file", f]);
|
||||
}
|
||||
args.extend(["config", "--format", "json"]);
|
||||
let mut cmd = command(ep, &args)?;
|
||||
cmd.current_dir(dir);
|
||||
let out = process::run(cmd, QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_daemon_urls() {
|
||||
for url in [
|
||||
"unix:///var/run/docker.sock",
|
||||
"/var/run/docker.sock",
|
||||
"npipe:////./pipe/docker_engine",
|
||||
"tcp://localhost:2375",
|
||||
"tcp://127.0.0.1:2376",
|
||||
"tcp://[::1]:2375",
|
||||
"",
|
||||
] {
|
||||
assert!(daemon_url_is_local(url), "{url}");
|
||||
}
|
||||
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
|
||||
assert!(!daemon_url_is_local(url), "{url}");
|
||||
}
|
||||
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
|
||||
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "ide-extension"
|
||||
description = "Plugin manifest, extension points, contribution registry and capability model (no Wasm runtime)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
semver = "1"
|
||||
blake3.workspace = true
|
||||
zip.workspace = true
|
||||
base64.workspace = true
|
||||
ed25519-dalek = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,279 @@
|
||||
//! What a plugin may do beyond pure computation, and how risky each grant is.
|
||||
//!
|
||||
//! Capabilities are written as strings in the manifest (`project.read`,
|
||||
//! `net:crates.io`, `process:cargo`) and stored the same way in the grant
|
||||
//! ledger. The 0.1 permission names (`project-read`, …) parse as their 0.2
|
||||
//! equivalents.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use specta::Type;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Capability {
|
||||
/// Read files inside the project root; the project root itself.
|
||||
ProjectRead,
|
||||
/// The focused editor's file, selection and text.
|
||||
EditorRead,
|
||||
/// Edit the focused editor (versioned edits).
|
||||
EditorWrite,
|
||||
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 (file opened, saved, focus…), not keystrokes.
|
||||
Events,
|
||||
/// Keystrokes and editor input events.
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
FsRead(String),
|
||||
}
|
||||
|
||||
/// How a grant is presented (plan 4.2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Risk {
|
||||
/// Listed on the install page, one confirmation.
|
||||
Low,
|
||||
/// Listed separately with its purpose.
|
||||
Medium,
|
||||
/// Separate dialog: "equivalent to running arbitrary code on this machine".
|
||||
High,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
pub fn risk(&self) -> Risk {
|
||||
match self {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => {
|
||||
Risk::Medium
|
||||
}
|
||||
Self::Process(_) | Self::FsRead(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-risk capabilities that the one-time migration of the 0.1 "declared
|
||||
/// means granted" behaviour may grant automatically (plan 4.6).
|
||||
pub fn auto_migrates(&self) -> bool {
|
||||
self.risk() == Risk::Low
|
||||
}
|
||||
|
||||
/// Short user-facing description for install and grant pages.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
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::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 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::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read 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 {
|
||||
match (self, wanted) {
|
||||
(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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `example.com` matches exactly; `*.example.com` matches any subdomain (not the apex).
|
||||
pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
let host = host.trim_end_matches('.').to_ascii_lowercase();
|
||||
let pattern = pattern.to_ascii_lowercase();
|
||||
match pattern.strip_prefix("*.") {
|
||||
Some(suffix) => host.len() > suffix.len() + 1 && host.ends_with(&format!(".{suffix}")),
|
||||
None => host == pattern,
|
||||
}
|
||||
}
|
||||
|
||||
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::VcsRead => f.write_str("vcs.read"),
|
||||
Self::VcsWrite => f.write_str("vcs.write"),
|
||||
Self::Storage => f.write_str("storage"),
|
||||
Self::Secrets => f.write_str("secrets"),
|
||||
Self::Events => f.write_str("events"),
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Capability {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
if let Some((kind, arg)) = s.split_once(':') {
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
&& host.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
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() {
|
||||
return Err(format!("{s:?}: use a program name or an absolute path"));
|
||||
}
|
||||
Ok(Self::Process(arg.to_owned()))
|
||||
}
|
||||
"fs.read" => {
|
||||
if !std::path::Path::new(arg).is_absolute() && !arg.starts_with('~') {
|
||||
return Err(format!("{s:?}: use an absolute path (or ~/…)"));
|
||||
}
|
||||
Ok(Self::FsRead(arg.to_owned()))
|
||||
}
|
||||
_ => Err(format!("unknown capability {s:?}")),
|
||||
};
|
||||
}
|
||||
Ok(match s {
|
||||
"project.read" | "project-read" => Self::ProjectRead,
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
"secrets" => Self::Secrets,
|
||||
"events" => Self::Events,
|
||||
"events.input" => Self::InputEvents,
|
||||
"clipboard" => Self::Clipboard,
|
||||
_ => return Err(format!("unknown capability {s:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Capability {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Capability {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A capability as the UI shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CapabilityInfo {
|
||||
/// `project.read`, `net:crates.io`, …
|
||||
pub id: String,
|
||||
pub risk: Risk,
|
||||
pub description: String,
|
||||
/// Declared under `optional`: the plugin runs without it.
|
||||
pub optional: bool,
|
||||
/// Currently granted.
|
||||
pub granted: bool,
|
||||
}
|
||||
|
||||
impl CapabilityInfo {
|
||||
pub fn new(cap: &Capability, optional: bool, granted: bool) -> Self {
|
||||
Self { id: cap.to_string(), risk: cap.risk(), description: cap.describe(), optional, granted }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_and_prints() {
|
||||
for s in [
|
||||
"project.read",
|
||||
"editor.write",
|
||||
"vcs.write",
|
||||
"storage",
|
||||
"secrets",
|
||||
"events",
|
||||
"events.input",
|
||||
"net:crates.io",
|
||||
"net:*.github.com",
|
||||
"process:cargo",
|
||||
] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
// Absolute paths are the platform's.
|
||||
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")
|
||||
};
|
||||
for s in [git, etc] {
|
||||
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
|
||||
}
|
||||
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
|
||||
assert!("net:".parse::<Capability>().is_err());
|
||||
assert!("net:a b".parse::<Capability>().is_err());
|
||||
assert!("process:./evil".parse::<Capability>().is_err());
|
||||
assert!("fs.read:rel".parse::<Capability>().is_err());
|
||||
assert!("network".parse::<Capability>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn risk_levels() {
|
||||
assert_eq!(Capability::ProjectRead.risk(), Risk::Low);
|
||||
assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium);
|
||||
assert_eq!(Capability::InputEvents.risk(), Risk::Medium);
|
||||
assert_eq!("clipboard".parse::<Capability>().unwrap(), Capability::Clipboard);
|
||||
assert_eq!(Capability::Clipboard.risk(), Risk::Medium);
|
||||
assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High);
|
||||
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_patterns() {
|
||||
assert!(host_matches("crates.io", "crates.io"));
|
||||
assert!(host_matches("crates.io", "CRATES.IO."));
|
||||
assert!(!host_matches("crates.io", "static.crates.io"));
|
||||
assert!(host_matches("*.github.com", "api.github.com"));
|
||||
assert!(!host_matches("*.github.com", "github.com"));
|
||||
assert!(!host_matches("*.github.com", "evilgithub.com"));
|
||||
let any_gh: Capability = "net:*.github.com".parse().unwrap();
|
||||
assert!(any_gh.covers(&"net:api.github.com".parse().unwrap()));
|
||||
assert!(Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etc/hosts".into())));
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The contribution index: validated UI
|
||||
//! declarations of installed packages, kept on disk so start-up does not
|
||||
//! validate them again (sanitising thousands of icon SVGs, say).
|
||||
//!
|
||||
//! Only packages with a [`Package::digest`] are indexed: installed copies,
|
||||
//! which the host never modifies after install. An entry is keyed by that
|
||||
//! digest, the extension point, the declaration and a salt naming the
|
||||
//! validation logic (the IDE version), so a new package version, a changed
|
||||
//! declaration or an IDE upgrade simply misses. A miss or an unreadable entry
|
||||
//! only costs the validation.
|
||||
|
||||
use crate::point::Package;
|
||||
use crate::ui::UiDecl;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`UiDecl`]'s serialized shape changes.
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
pub struct ContributionIndex {
|
||||
dir: PathBuf,
|
||||
salt: String,
|
||||
}
|
||||
|
||||
impl ContributionIndex {
|
||||
/// `salt` names the validators' version (the IDE version).
|
||||
pub fn new(dir: PathBuf, salt: &str) -> Self {
|
||||
Self { dir, salt: format!("{FORMAT}:{salt}") }
|
||||
}
|
||||
|
||||
/// Entries start with a prefix of the package digest, for pruning.
|
||||
fn file(&self, digest: &str, point: &str, decl: &toml::Value) -> PathBuf {
|
||||
let mut h = blake3::Hasher::new();
|
||||
for part in [self.salt.as_str(), digest, point, &decl.to_string()] {
|
||||
h.update(part.as_bytes());
|
||||
h.update(&[0]);
|
||||
}
|
||||
let key = h.finalize().to_hex();
|
||||
self.dir.join(format!("{}-{}.json", prefix(digest), &key[..32]))
|
||||
}
|
||||
|
||||
pub fn get(&self, pkg: &Package, point: &str, decl: &toml::Value) -> Option<UiDecl> {
|
||||
let digest = pkg.digest.as_deref()?;
|
||||
let bytes = std::fs::read(self.file(digest, point, decl)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
pub fn put(&self, pkg: &Package, point: &str, decl: &toml::Value, ui: &UiDecl) {
|
||||
let Some(digest) = pkg.digest.as_deref() else { return };
|
||||
let Ok(bytes) = serde_json::to_vec(ui) else { return };
|
||||
let file = self.file(digest, point, decl);
|
||||
let _ = std::fs::create_dir_all(&self.dir);
|
||||
let tmp = file.with_extension(format!("tmp{}", std::process::id()));
|
||||
if std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries of packages other than `live` (digests).
|
||||
pub fn prune<'a>(&self, live: impl IntoIterator<Item = &'a str>) {
|
||||
let keep: BTreeSet<String> = live.into_iter().map(|d| prefix(d).to_owned()).collect();
|
||||
let Ok(entries) = std::fs::read_dir(&self.dir) else { return };
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !name.split_once('-').is_some_and(|(p, _)| keep.contains(p)) {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(digest: &str) -> &str {
|
||||
let d = digest.strip_prefix("blake3:").unwrap_or(digest);
|
||||
let end = d.char_indices().nth(16).map_or(d.len(), |(i, _)| i);
|
||||
&d[..end]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
#[test]
|
||||
fn hits_only_the_same_package_point_declaration_and_salt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let index = ContributionIndex::new(tmp.path().join("idx"), "1.0");
|
||||
let manifest =
|
||||
Manifest::parse("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n").unwrap();
|
||||
let pkg = Package::new(manifest, tmp.path().to_path_buf()).with_digest("blake3:00aa11bb22cc33dd44ee");
|
||||
let decl: toml::Value = toml::from_str("id = \"x\"").unwrap();
|
||||
let ui = UiDecl::new("x", "X", serde_json::json!({ "id": "x" }));
|
||||
assert!(index.get(&pkg, "pt", &decl).is_none());
|
||||
index.put(&pkg, "pt", &decl, &ui);
|
||||
assert_eq!(index.get(&pkg, "pt", &decl).unwrap().label, "X");
|
||||
assert!(index.get(&pkg, "other", &decl).is_none());
|
||||
assert!(index.get(&pkg, "pt", &toml::from_str("id = \"y\"").unwrap()).is_none());
|
||||
assert!(ContributionIndex::new(tmp.path().join("idx"), "1.1").get(&pkg, "pt", &decl).is_none());
|
||||
let unhashed = Package { digest: None, ..pkg.clone() };
|
||||
assert!(index.get(&unhashed, "pt", &decl).is_none(), "not an installed copy");
|
||||
|
||||
index.prune(["blake3:ffff"]);
|
||||
assert!(index.get(&pkg, "pt", &decl).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
//! The grant ledger (plan 4.6): which capabilities the user granted to which
|
||||
//! plugin, saved as `grants.json` next to the installed plugins.
|
||||
//!
|
||||
//! - Installed plugins are keyed by id and remember the version and package
|
||||
//! hash they were confirmed for. An update that needs no new capability is
|
||||
//! accepted silently; one that does must be confirmed again.
|
||||
//! - Linked development plugins and project plugins (`.openidea/plugins/*`)
|
||||
//! are keyed by their directory, not by hash, so rebuilding does not ask
|
||||
//! again; new capabilities still do.
|
||||
//! - `process:<program>` grants record the program's absolute path and file
|
||||
//! hash at grant time. Calls use that path, never a `PATH` lookup, and fail
|
||||
//! when the file changed (re-confirmation needed).
|
||||
|
||||
use crate::capability::{Capability, Risk};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const LEDGER_FILE: &str = "grants.json";
|
||||
const FORMAT: u32 = 1;
|
||||
|
||||
/// Whose grant an entry is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum Subject {
|
||||
/// Installed under the plugins directory, by plugin id.
|
||||
Installed(String),
|
||||
/// A linked development plugin or a project plugin, by package directory.
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryGrant {
|
||||
pub path: PathBuf,
|
||||
/// blake3 of the file when granted.
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Grant {
|
||||
pub plugin_id: String,
|
||||
/// The version and package hash the user confirmed (installed plugins).
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub package_hash: String,
|
||||
/// The user confirmed the plugin (the install page or the project-plugin prompt).
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
/// Granted capabilities (`project.read`, `net:x`, …).
|
||||
#[serde(default)]
|
||||
pub granted: 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>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
pub ask_on_use: bool,
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn has(&self, cap: &Capability) -> bool {
|
||||
self.granted.iter().filter_map(|s| s.parse::<Capability>().ok()).any(|g| g.covers(cap))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a plugin (in its current form) needs, to compare with its grant.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Request<'a> {
|
||||
pub subject: Subject,
|
||||
pub plugin_id: &'a str,
|
||||
pub version: &'a str,
|
||||
pub package_hash: &'a str,
|
||||
pub required: Vec<Capability>,
|
||||
pub optional: Vec<Capability>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// Load with these capabilities. `pending` required ones are asked for when
|
||||
/// the plugin is first used (plugins migrated from 0.1 only).
|
||||
Allowed { granted: Vec<Capability>, pending: Vec<Capability> },
|
||||
/// Ask the user first. `new` lists what was never granted (all of it on first use).
|
||||
NeedsConfirmation { new: Vec<Capability>, first_time: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
format: u32,
|
||||
/// The one-time migration from 0.1 ("declared means granted") ran.
|
||||
#[serde(default)]
|
||||
migrated: bool,
|
||||
#[serde(default)]
|
||||
grants: Vec<(Subject, Grant)>,
|
||||
}
|
||||
|
||||
/// The ledger, loaded into memory; [`Ledger::save`] writes it atomically.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Ledger {
|
||||
path: Option<PathBuf>,
|
||||
migrated: bool,
|
||||
grants: BTreeMap<Subject, Grant>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
/// Loads `<dir>/grants.json`; a missing or unreadable file gives an empty
|
||||
/// ledger with `needs_migration()` true.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
let path = dir.join(LEDGER_FILE);
|
||||
let file: Option<File> = std::fs::read_to_string(&path).ok().and_then(|s| match serde_json::from_str(&s) {
|
||||
Ok(f) => Some(f),
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: {e}; starting with an empty grant ledger", path.display());
|
||||
None
|
||||
}
|
||||
});
|
||||
let existed = file.is_some();
|
||||
let file = file.unwrap_or_default();
|
||||
Self { path: Some(path), migrated: file.migrated || existed, grants: file.grants.into_iter().collect() }
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
Self { path: None, migrated: true, grants: BTreeMap::new() }
|
||||
}
|
||||
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let Some(path) = &self.path else { return Ok(()) };
|
||||
let file = File {
|
||||
format: FORMAT,
|
||||
migrated: self.migrated,
|
||||
grants: self.grants.iter().map(|(s, g)| (s.clone(), g.clone())).collect(),
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&file).map_err(std::io::Error::other)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
pub fn get(&self, subject: &Subject) -> Option<&Grant> {
|
||||
self.grants.get(subject)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, subject: &Subject) -> Option<Grant> {
|
||||
self.grants.remove(subject)
|
||||
}
|
||||
|
||||
/// Puts back an earlier state of `subject`'s grant (`None`: none), e.g.
|
||||
/// after rolling back an update.
|
||||
pub fn restore(&mut self, subject: &Subject, grant: Option<Grant>) {
|
||||
match grant {
|
||||
Some(g) => {
|
||||
self.grants.insert(subject.clone(), g);
|
||||
}
|
||||
None => {
|
||||
self.grants.remove(subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-time migration of 0.1's "declared means granted" has not run yet.
|
||||
pub fn needs_migration(&self) -> bool {
|
||||
!self.migrated
|
||||
}
|
||||
|
||||
/// Migrates plugins installed before the ledger existed: low-risk
|
||||
/// capabilities are granted, the rest are asked on first use.
|
||||
pub fn migrate(&mut self, installed: &[Request<'_>]) {
|
||||
for req in installed {
|
||||
let granted: BTreeSet<String> =
|
||||
req.required.iter().chain(&req.optional).filter(|c| c.auto_migrates()).map(|c| c.to_string()).collect();
|
||||
self.grants.entry(req.subject.clone()).or_insert_with(|| Grant {
|
||||
plugin_id: req.plugin_id.to_owned(),
|
||||
version: req.version.to_owned(),
|
||||
package_hash: req.package_hash.to_owned(),
|
||||
confirmed: true,
|
||||
granted,
|
||||
ask_on_use: true,
|
||||
..Grant::default()
|
||||
});
|
||||
}
|
||||
self.migrated = true;
|
||||
}
|
||||
|
||||
/// Compares what the plugin needs with what was granted.
|
||||
pub fn decide(&mut self, req: &Request<'_>) -> Decision {
|
||||
let Some(grant) = self.grants.get_mut(&req.subject) else {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
};
|
||||
if !grant.confirmed {
|
||||
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
|
||||
}
|
||||
let new: Vec<Capability> = req.required.iter().filter(|c| !grant.has(c)).cloned().collect();
|
||||
if !new.is_empty() && !grant.ask_on_use {
|
||||
return Decision::NeedsConfirmation { new, first_time: false };
|
||||
}
|
||||
// Same or fewer needs: accept an update silently (plan 4.6).
|
||||
if grant.version != req.version || grant.package_hash != req.package_hash {
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
}
|
||||
let granted = req.required.iter().chain(&req.optional).filter(|c| grant.has(c)).cloned().collect();
|
||||
Decision::Allowed { granted, pending: new }
|
||||
}
|
||||
|
||||
/// Records the user's confirmation: `required` plus the accepted optional capabilities.
|
||||
pub fn confirm(
|
||||
&mut self,
|
||||
req: &Request<'_>,
|
||||
accepted_optional: &[Capability],
|
||||
binaries: BTreeMap<String, BinaryGrant>,
|
||||
) {
|
||||
let grant = self.grants.entry(req.subject.clone()).or_default();
|
||||
grant.plugin_id = req.plugin_id.to_owned();
|
||||
grant.version = req.version.to_owned();
|
||||
grant.package_hash = req.package_hash.to_owned();
|
||||
grant.confirmed = true;
|
||||
grant.ask_on_use = false;
|
||||
for cap in req.required.iter().chain(accepted_optional) {
|
||||
grant.granted.insert(cap.to_string());
|
||||
}
|
||||
for cap in &req.optional {
|
||||
let s = cap.to_string();
|
||||
if accepted_optional.contains(cap) {
|
||||
grant.declined.remove(&s);
|
||||
} else if !grant.granted.contains(&s) {
|
||||
grant.declined.insert(s);
|
||||
}
|
||||
}
|
||||
grant.binaries.extend(binaries);
|
||||
}
|
||||
|
||||
/// Grants or revokes one capability (Settings › Plugins › permissions).
|
||||
pub fn set(&mut self, subject: &Subject, cap: &Capability, granted: bool) -> bool {
|
||||
let Some(grant) = self.grants.get_mut(subject) else { return false };
|
||||
let s = cap.to_string();
|
||||
if granted {
|
||||
grant.declined.remove(&s);
|
||||
grant.granted.insert(s)
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
grant.binaries.insert(cap.to_string(), binary);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let Capability::Process(name) = cap else { return Err(format!("{cap} is not a process capability")) };
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
let key = cap.to_string();
|
||||
if !grant.granted.contains(&key) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
let bin = grant.binaries.get(&key).ok_or_else(|| format!("{cap}: no program was confirmed for {name}"))?;
|
||||
match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
Ok(_) => Err(format!(
|
||||
"{} changed since it was approved for {cap}; confirm the plugin's permissions again",
|
||||
bin.path.display()
|
||||
)),
|
||||
Err(e) => Err(format!("{}: {e}; confirm the plugin's permissions again", bin.path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The program approved for a granted `process:` capability, without
|
||||
/// checking the file: whoever starts it verifies the hash then
|
||||
/// ([`check_binary`](Self::check_binary) or the pin's own check).
|
||||
pub fn pinned_binary(&self, subject: &Subject, cap: &Capability) -> Option<BinaryGrant> {
|
||||
let grant = self.grants.get(subject)?;
|
||||
let key = cap.to_string();
|
||||
if !grant.granted.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
grant.binaries.get(&key).cloned()
|
||||
}
|
||||
|
||||
pub fn subjects(&self) -> impl Iterator<Item = (&Subject, &Grant)> {
|
||||
self.grants.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a confirmation page shows: every capability with its risk, high risk first.
|
||||
pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
let mut groups: BTreeMap<std::cmp::Reverse<Risk>, Vec<Capability>> = BTreeMap::new();
|
||||
for c in caps {
|
||||
groups.entry(std::cmp::Reverse(c.risk())).or_default().push(c.clone());
|
||||
}
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
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) {
|
||||
std::env::var("PATHEXT").unwrap_or_else(|_| ".EXE;.CMD;.BAT".into()).split(';').map(str::to_owned).collect()
|
||||
} else {
|
||||
vec![String::new()]
|
||||
};
|
||||
for dir in std::env::split_paths(path_var.unwrap_or_default()) {
|
||||
if !dir.is_absolute() {
|
||||
continue; // `.` or relative entries could point into the project
|
||||
}
|
||||
for ext in &exts {
|
||||
let p = dir.join(format!("{name}{ext}"));
|
||||
if is_executable(&p) {
|
||||
return Ok(dunce_canonical(&p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("{name} was not found on PATH"))
|
||||
}
|
||||
|
||||
fn dunce_canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
let Ok(meta) = std::fs::metadata(p) else { return false };
|
||||
if !meta.is_file() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
true
|
||||
}
|
||||
|
||||
/// blake3 of a file, hex.
|
||||
pub fn file_hash(path: &Path) -> std::io::Result<String> {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update_reader(std::fs::File::open(path)?)?;
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// blake3 over a package's files (sorted relative paths and contents).
|
||||
pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
let mut sorted: Vec<&PathBuf> = files.iter().collect();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for rel in sorted {
|
||||
hasher.update(hashed_path(rel).as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
|
||||
hasher.update(&[0]);
|
||||
}
|
||||
Ok(hasher.finalize().to_hex().to_string())
|
||||
}
|
||||
|
||||
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
|
||||
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
|
||||
fn hashed_path(rel: &Path) -> String {
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn caps(list: &[&str]) -> Vec<Capability> {
|
||||
list.iter().map(|s| s.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn req<'a>(version: &'a str, hash: &'a str, required: &[&str], optional: &[&str]) -> Request<'a> {
|
||||
Request {
|
||||
subject: Subject::Installed("p".into()),
|
||||
plugin_id: "p",
|
||||
version,
|
||||
package_hash: hash,
|
||||
required: caps(required),
|
||||
optional: caps(optional),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_then_silent_update_then_reconfirm_on_new_capability() {
|
||||
let mut l = Ledger::in_memory();
|
||||
let r1 = req("1.0.0", "h1", &["project.read"], &["net:crates.io"]);
|
||||
assert_eq!(l.decide(&r1), Decision::NeedsConfirmation { new: caps(&["project.read"]), first_time: true });
|
||||
l.confirm(&r1, &[], BTreeMap::new());
|
||||
assert_eq!(
|
||||
l.decide(&r1),
|
||||
Decision::Allowed { granted: caps(&["project.read"]), pending: vec![] },
|
||||
"declined optional"
|
||||
);
|
||||
assert!(l.get(&r1.subject).unwrap().declined.contains("net:crates.io"));
|
||||
|
||||
// Same needs, new version: silent.
|
||||
let r2 = req("1.1.0", "h2", &["project.read"], &["net:crates.io"]);
|
||||
assert!(matches!(l.decide(&r2), Decision::Allowed { .. }));
|
||||
assert_eq!(l.get(&r2.subject).unwrap().version, "1.1.0");
|
||||
|
||||
// A new required capability: ask again, only for it.
|
||||
let r3 = req("1.2.0", "h3", &["project.read", "editor.write"], &[]);
|
||||
assert_eq!(l.decide(&r3), Decision::NeedsConfirmation { new: caps(&["editor.write"]), first_time: false });
|
||||
l.confirm(&r3, &[], BTreeMap::new());
|
||||
assert!(matches!(l.decide(&r3), Decision::Allowed { .. }));
|
||||
|
||||
// Revoking a required capability blocks loading again.
|
||||
assert!(l.set(&r3.subject, &Capability::EditorWrite, false));
|
||||
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_grants_low_risk_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut l = Ledger::load(tmp.path());
|
||||
assert!(l.needs_migration());
|
||||
let r = req("0.1.0", "h", &["project.read", "editor.write"], &[]);
|
||||
l.migrate(std::slice::from_ref(&r));
|
||||
// Loads with the low-risk part; the rest is asked for on first use.
|
||||
assert_eq!(
|
||||
l.decide(&r),
|
||||
Decision::Allowed { granted: caps(&["project.read"]), pending: caps(&["editor.write"]) }
|
||||
);
|
||||
l.confirm(&r, &[], BTreeMap::new());
|
||||
assert_eq!(
|
||||
l.decide(&r),
|
||||
Decision::Allowed { granted: caps(&["project.read", "editor.write"]), pending: vec![] }
|
||||
);
|
||||
l.save().unwrap();
|
||||
let again = Ledger::load(tmp.path());
|
||||
assert!(!again.needs_migration());
|
||||
assert!(again.get(&r.subject).unwrap().has(&Capability::ProjectRead));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn binaries_are_pinned_by_path_and_hash() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin_dir).unwrap();
|
||||
let tool = bin_dir.join("tool");
|
||||
std::fs::write(&tool, "#!/bin/sh\necho v1\n").unwrap();
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path_var = std::env::join_paths([PathBuf::from("relative"), bin_dir.clone()]).unwrap();
|
||||
let resolved = resolve_program("tool", Some(&path_var)).unwrap();
|
||||
assert!(resolved.is_absolute());
|
||||
assert!(resolve_program("missing", Some(&path_var)).is_err());
|
||||
|
||||
let mut l = Ledger::in_memory();
|
||||
let r = req("1.0.0", "h", &["process:tool"], &[]);
|
||||
let cap: Capability = "process:tool".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(&tool, "#!/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();
|
||||
std::fs::write(tmp.path().join("a"), "1").unwrap();
|
||||
std::fs::write(tmp.path().join("b"), "2").unwrap();
|
||||
let h1 = package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap();
|
||||
assert_eq!(h1, package_hash(tmp.path(), &["b".into(), "a".into()]).unwrap());
|
||||
std::fs::write(tmp.path().join("b"), "3").unwrap();
|
||||
assert_ne!(h1, package_hash(tmp.path(), &["a".into(), "b".into()]).unwrap());
|
||||
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
|
||||
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_paths_with_forward_slashes_on_every_os() {
|
||||
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
|
||||
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
|
||||
#[cfg(windows)]
|
||||
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("web")).unwrap();
|
||||
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
|
||||
// A walked path (native separators) hashes like the manifest's `/` spelling.
|
||||
assert_eq!(
|
||||
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
|
||||
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The plugin system's kernel, independent of the Wasm runtime:
|
||||
//!
|
||||
//! - [`Manifest`]: `plugin.toml` (0.2 format; 0.1 still parses);
|
||||
//! - [`Capability`]: what a plugin may do, with its [`Risk`] level;
|
||||
//! - [`ExtensionPoint`]: one kind of contribution, implemented by the crate that
|
||||
//! owns the feature; [`ExtensionRegistry`] validates a package's declarations
|
||||
//! ([`ExtensionRegistry::prepare`], no side effects), makes them take effect
|
||||
//! all or nothing ([`ExtensionRegistry::commit`]) and withdraws them per plugin;
|
||||
//! - [`UiStore`] / [`UiPoint`]: UI contributions, validated here and projected
|
||||
//! by the client without the possibility to fail;
|
||||
//! - [`ledger`]: which capabilities the user granted to which plugin;
|
||||
//! - [`package`]: the package file, its signature and the marketplace index.
|
||||
//!
|
||||
//! Dependency direction: `ide-core` ← `ide-extension` ← feature crates (their
|
||||
//! extension points) and `ide-plugin` (Wasm) ← `ide-host` (assembly).
|
||||
|
||||
mod capability;
|
||||
mod index;
|
||||
pub mod ledger;
|
||||
mod manifest;
|
||||
pub mod package;
|
||||
mod point;
|
||||
mod registry;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
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 point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -0,0 +1,567 @@
|
||||
//! `plugin.toml`, the package manifest (plan 4.4).
|
||||
//!
|
||||
//! ```toml
|
||||
//! id = "acme.rust-tools" # optional publisher namespace before the first '.'
|
||||
//! name = "Rust Tools"
|
||||
//! version = "1.2.0"
|
||||
//! engines.openidea = "^1.0"
|
||||
//! wasm = "plugin.wasm"
|
||||
//! activation = ["on-language:rust", "on-command:check"]
|
||||
//!
|
||||
//! [capabilities]
|
||||
//! required = ["project.read", "editor.write"]
|
||||
//! optional = ["net:crates.io", "process:cargo"]
|
||||
//!
|
||||
//! [[contributes.commands]]
|
||||
//! id = "check"
|
||||
//! title = "Check"
|
||||
//! ```
|
||||
//!
|
||||
//! `contributes` is kept as raw TOML: each `contributes.<point>` is parsed and
|
||||
//! validated by the extension point registered under that id
|
||||
//! ([`crate::ExtensionRegistry`]), so new contribution types need no change here.
|
||||
//!
|
||||
//! Manifests in the 0.1 format (`openidea-api = "0.1"`, `permissions = [...]`)
|
||||
//! still parse: permissions become required capabilities and the plugin is
|
||||
//! activated at start-up, as before.
|
||||
|
||||
use crate::capability::Capability;
|
||||
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.2.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.
|
||||
pub const CATEGORIES: &[&str] =
|
||||
&["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"];
|
||||
/// Categories a plugin is listed under; later ones are ignored.
|
||||
pub const MAX_CATEGORIES: usize = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Manifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
/// Release notes of this version (Settings › Plugins › What's New).
|
||||
pub change_notes: Option<String>,
|
||||
/// Marketplace categories as declared (see [`Manifest::categories`]).
|
||||
pub declared_categories: Vec<String>,
|
||||
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
|
||||
pub engine: String,
|
||||
/// Written in the 0.1 format.
|
||||
pub legacy: bool,
|
||||
/// Component file relative to the package directory.
|
||||
pub wasm: Option<PathBuf>,
|
||||
pub activation: Vec<ActivationEvent>,
|
||||
pub capabilities: Capabilities,
|
||||
/// `contributes.<point>` → its declarations, validated by the extension points.
|
||||
pub contributes: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Capabilities {
|
||||
pub required: Vec<Capability>,
|
||||
pub optional: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
pub fn all(&self) -> impl Iterator<Item = &Capability> {
|
||||
self.required.iter().chain(&self.optional)
|
||||
}
|
||||
|
||||
pub fn is_optional(&self, cap: &Capability) -> bool {
|
||||
self.optional.contains(cap) && !self.required.contains(cap)
|
||||
}
|
||||
}
|
||||
|
||||
/// When a plugin's code is instantiated (plan 4.4). Contributions are live
|
||||
/// without it; code only runs once one of these fires.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ActivationEvent {
|
||||
/// One of the plugin's commands is run (`on-command:<id>`; `*` = any).
|
||||
Command(String),
|
||||
/// A file of this language is opened.
|
||||
Language(String),
|
||||
/// A file matching this glob (project-relative) is opened.
|
||||
File(String),
|
||||
/// One of the plugin's views is shown.
|
||||
View(String),
|
||||
/// Periodically: `on-schedule:<seconds>`. Does not keep the plugin alive.
|
||||
Schedule(u32),
|
||||
/// A workbench event of this kind (`on-event:file-saved`): the plugin is
|
||||
/// subscribed from the start, and activated to receive it.
|
||||
Event(String),
|
||||
/// Shortly after the workbench is ready.
|
||||
StartupFinished,
|
||||
}
|
||||
|
||||
impl ActivationEvent {
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
let s = s.trim();
|
||||
if s == "on-startup-finished" {
|
||||
return Ok(Self::StartupFinished);
|
||||
}
|
||||
let (kind, arg) = s.split_once(':').ok_or_else(|| format!("unknown activation event {s:?}"))?;
|
||||
let arg = arg.trim();
|
||||
if arg.is_empty() {
|
||||
return Err(format!("activation event {s:?} needs a value"));
|
||||
}
|
||||
Ok(match kind {
|
||||
"on-command" => Self::Command(arg.into()),
|
||||
"on-language" => Self::Language(arg.into()),
|
||||
"on-file" => Self::File(arg.into()),
|
||||
"on-view" => Self::View(arg.into()),
|
||||
"on-schedule" => {
|
||||
let secs: u32 =
|
||||
arg.trim_end_matches('s').parse().map_err(|_| format!("{s:?}: expected seconds, e.g. 300"))?;
|
||||
if secs < 60 {
|
||||
return Err(format!("{s:?}: the shortest schedule is 60 seconds"));
|
||||
}
|
||||
Self::Schedule(secs)
|
||||
}
|
||||
"on-event" => {
|
||||
if !EVENT_KINDS.contains(&arg) {
|
||||
return Err(format!("{s:?}: unknown event kind (one of {})", EVENT_KINDS.join(", ")));
|
||||
}
|
||||
Self::Event(arg.into())
|
||||
}
|
||||
_ => return Err(format!("unknown activation event {s:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
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 {
|
||||
match self {
|
||||
Self::Command(c) => write!(f, "on-command:{c}"),
|
||||
Self::Language(l) => write!(f, "on-language:{l}"),
|
||||
Self::File(g) => write!(f, "on-file:{g}"),
|
||||
Self::View(v) => write!(f, "on-view:{v}"),
|
||||
Self::Schedule(s) => write!(f, "on-schedule:{s}"),
|
||||
Self::Event(k) => write!(f, "on-event:{k}"),
|
||||
Self::StartupFinished => f.write_str("on-startup-finished"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
|
||||
struct Raw {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
license: Option<String>,
|
||||
#[serde(default)]
|
||||
change_notes: Option<String>,
|
||||
#[serde(default)]
|
||||
categories: Vec<String>,
|
||||
#[serde(default)]
|
||||
engines: Option<Engines>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
openidea_api: Option<String>,
|
||||
#[serde(default)]
|
||||
wasm: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
activation: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
capabilities: Option<RawCapabilities>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
contributes: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Engines {
|
||||
openidea: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawCapabilities {
|
||||
#[serde(default)]
|
||||
required: Vec<String>,
|
||||
#[serde(default)]
|
||||
optional: Vec<String>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(src: &str) -> Result<Self, String> {
|
||||
let raw: Raw = toml::from_str(src).map_err(|e| e.to_string())?;
|
||||
let (engine, legacy) = match (raw.engines, raw.openidea_api) {
|
||||
(Some(e), None) => (e.openidea, false),
|
||||
(None, Some(api)) => (api, true),
|
||||
(Some(_), Some(_)) => return Err("use `engines.openidea`, not `openidea-api`".into()),
|
||||
(None, None) => return Err("missing `engines.openidea` (e.g. \"^1.0\")".into()),
|
||||
};
|
||||
if legacy && raw.capabilities.is_some() {
|
||||
return Err("`capabilities` requires `engines.openidea`; 0.1 manifests use `permissions`".into());
|
||||
}
|
||||
if !legacy && raw.permissions.is_some() {
|
||||
return Err("`permissions` is the 0.1 format; use `[capabilities] required = [...]`".into());
|
||||
}
|
||||
let parse_caps = |list: Vec<String>| -> Result<Vec<Capability>, String> {
|
||||
let mut out: Vec<Capability> = Vec::new();
|
||||
for s in list {
|
||||
let cap: Capability = s.parse()?;
|
||||
if !out.contains(&cap) {
|
||||
out.push(cap);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let capabilities = match (raw.capabilities, raw.permissions) {
|
||||
(Some(c), _) => Capabilities { required: parse_caps(c.required)?, optional: parse_caps(c.optional)? },
|
||||
(None, Some(p)) => Capabilities { required: parse_caps(p)?, optional: Vec::new() },
|
||||
(None, None) => Capabilities::default(),
|
||||
};
|
||||
let activation = match raw.activation {
|
||||
Some(list) => list.iter().map(|s| ActivationEvent::parse(s)).collect::<Result<Vec<_>, _>>()?,
|
||||
// 0.1 plugins were activated at start-up; keep that until they move to 1.0.
|
||||
None if legacy && raw.wasm.is_some() => vec![ActivationEvent::StartupFinished],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let m = Manifest {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
version: raw.version,
|
||||
description: raw.description,
|
||||
authors: raw.authors,
|
||||
homepage: raw.homepage,
|
||||
license: raw.license,
|
||||
change_notes: raw.change_notes,
|
||||
declared_categories: raw.categories,
|
||||
engine,
|
||||
legacy,
|
||||
wasm: raw.wasm,
|
||||
activation,
|
||||
capabilities,
|
||||
contributes: raw.contributes,
|
||||
};
|
||||
m.validate()?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Result<Self, String> {
|
||||
let path = dir.join(MANIFEST_FILE);
|
||||
let src = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
Self::parse(&src).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
validate_plugin_id(&self.id)?;
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("`name` must not be empty".into());
|
||||
}
|
||||
if semver::Version::parse(&self.version).is_err() {
|
||||
return Err(format!("`version` must be a semantic version like 1.2.3, got {:?}", self.version));
|
||||
}
|
||||
if !engine_compatible(&self.engine, self.legacy) {
|
||||
return Err(if self.legacy {
|
||||
format!(
|
||||
"unsupported openidea-api {:?} (this IDE implements {LEGACY_API} and {ENGINE_VERSION})",
|
||||
self.engine
|
||||
)
|
||||
} else {
|
||||
format!("engines.openidea {:?} does not match this IDE's plugin API {ENGINE_VERSION}", self.engine)
|
||||
});
|
||||
}
|
||||
if let Some(wasm) = &self.wasm {
|
||||
check_relative(wasm, "wasm")?;
|
||||
}
|
||||
if self.wasm.is_none() && !self.activation.is_empty() {
|
||||
return Err("`activation` needs `wasm`: there is no code to activate".into());
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if !seen.insert(event) {
|
||||
return Err(format!("duplicate activation event {event}"));
|
||||
}
|
||||
}
|
||||
for c in &self.declared_categories {
|
||||
validate_item_id(c, "category")?;
|
||||
}
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
return Err(format!("`contributes.{point}` must be a table or an array of tables"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The declarations of one extension point: `[[contributes.x]]` gives
|
||||
/// several, `[contributes.x]` one.
|
||||
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
|
||||
match self.contributes.get(point) {
|
||||
Some(toml::Value::Array(items)) => items.clone(),
|
||||
Some(single @ toml::Value::Table(_)) => vec![single.clone()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of declarations per extension point (for listings before loading).
|
||||
pub fn declaration_counts(&self) -> Vec<(String, u32)> {
|
||||
self.contributes.keys().map(|p| (p.clone(), self.declarations(p).len() as u32)).collect()
|
||||
}
|
||||
|
||||
/// Publisher namespace: the id's part before the first `.` (`acme` in `acme.tools`).
|
||||
pub fn publisher(&self) -> Option<&str> {
|
||||
self.id.split_once('.').map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Whether `cap` is declared (required or optional).
|
||||
pub fn declares(&self, cap: &Capability) -> bool {
|
||||
self.capabilities.all().any(|c| c.covers(cap))
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Its marketplace categories, in [`CATEGORIES`] order: the first
|
||||
/// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its
|
||||
/// contributions imply (themes, icon themes, languages), else `other`.
|
||||
pub fn categories(&self) -> Vec<String> {
|
||||
let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
|
||||
let mut known: Vec<&str> = Vec::new();
|
||||
for d in &self.declared_categories {
|
||||
if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES {
|
||||
known.push(d);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<String> = pick(&|c| known.contains(&c));
|
||||
if out.is_empty() {
|
||||
out = pick(&|c| match c {
|
||||
"themes" => self.contributes.contains_key("themes"),
|
||||
"icon-themes" => self.contributes.contains_key("iconThemes"),
|
||||
"languages" => self.contributes.contains_key("languages"),
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push("other".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Declared categories this IDE doesn't know (ignored).
|
||||
pub fn unknown_categories(&self) -> Vec<&str> {
|
||||
self.declared_categories.iter().map(String::as_str).filter(|c| !CATEGORIES.contains(c)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this host can load a plugin written for `engine`.
|
||||
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
|
||||
if legacy {
|
||||
return engine == LEGACY_API || engine.strip_prefix(LEGACY_API).is_some_and(|rest| rest.starts_with('.'));
|
||||
}
|
||||
let Ok(req) = semver::VersionReq::parse(engine) else { return false };
|
||||
let host = semver::Version::parse(ENGINE_VERSION).expect("valid engine version");
|
||||
req.matches(&host)
|
||||
}
|
||||
|
||||
/// Segments of `[a-z0-9][a-z0-9_-]*` joined by `.` (an optional publisher
|
||||
/// namespace first), at most 64 chars.
|
||||
pub fn validate_plugin_id(id: &str) -> Result<(), String> {
|
||||
let ok = id.len() <= 64 && !id.is_empty() && id.split('.').all(segment_ok);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"invalid plugin id {id:?}: use a-z, 0-9, '-', '_' in '.'-separated parts (e.g. acme.tools), max 64"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a-z0-9][a-z0-9._-]*`, at most 64 chars (commands, themes, views…).
|
||||
pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
|
||||
let ok_first = id.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||
let ok_rest = id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
|
||||
if ok_first && ok_rest && id.len() <= 64 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("invalid {what} {id:?}: use a-z, 0-9, '.', '-', '_', starting with a letter or digit (max 64)"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
|
||||
pub fn validate_point_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
|
||||
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
|
||||
}
|
||||
|
||||
fn segment_ok(s: &str) -> bool {
|
||||
s.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Package paths must stay inside the package directory.
|
||||
pub fn check_relative(path: &Path, what: &str) -> Result<(), String> {
|
||||
let ok = !path.as_os_str().is_empty() && path.components().all(|c| matches!(c, Component::Normal(_)));
|
||||
if ok { Ok(()) } else { Err(format!("{what} {:?} must be a relative path inside the plugin", path.display())) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FULL: &str = r#"
|
||||
id = "acme.rust-tools"
|
||||
name = "Rust Tools"
|
||||
version = "1.2.0"
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "plugin.wasm"
|
||||
activation = ["on-language:rust", "on-command:check", "on-schedule:300"]
|
||||
|
||||
[capabilities]
|
||||
required = ["project.read", "editor.write"]
|
||||
optional = ["net:crates.io", "process:cargo"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "check"
|
||||
title = "%check.title%"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "editor.context"
|
||||
command = "check"
|
||||
when = "editorLang == rust"
|
||||
|
||||
[contributes.settings]
|
||||
schema = "settings.schema.json"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
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");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_are_declared_or_implied() {
|
||||
let head = "id = \"acme.x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
|
||||
let m = |rest: &str| Manifest::parse(&format!("{head}{rest}"));
|
||||
// Declared, in the catalogue's order; unknown ones (a newer IDE's) are ignored.
|
||||
let declared = m("categories = [\"tools\", \"vcs\", \"robots\"]\n").unwrap();
|
||||
assert_eq!(declared.categories(), ["vcs", "tools"]);
|
||||
assert_eq!(declared.unknown_categories(), ["robots"]);
|
||||
// None declared: implied by the contributions, else `other`.
|
||||
let theme = m("[[contributes.themes]]\nid = \"t\"\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
|
||||
assert_eq!(theme.categories(), ["languages", "themes"]);
|
||||
let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
|
||||
assert_eq!(declared_wins.categories(), ["tools"]);
|
||||
assert_eq!(m("").unwrap().categories(), ["other"]);
|
||||
// At most three: the first ones declared.
|
||||
let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap();
|
||||
assert_eq!(many.categories(), ["vcs", "ai", "tools"]);
|
||||
assert!(m("categories = [\"Bad Name\"]\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_02_format() {
|
||||
let m = Manifest::parse(FULL).unwrap();
|
||||
assert_eq!(m.publisher(), Some("acme"));
|
||||
assert!(!m.legacy);
|
||||
assert_eq!(m.activation.len(), 3);
|
||||
assert_eq!(m.activation[2], ActivationEvent::Schedule(300));
|
||||
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
|
||||
assert!(m.capabilities.is_optional(&Capability::Process("cargo".into())));
|
||||
assert_eq!(m.declarations("commands").len(), 1);
|
||||
assert_eq!(m.declarations("settings").len(), 1, "a single table is one declaration");
|
||||
assert!(m.declarations("nope").is_empty());
|
||||
let counts: Vec<_> = m.declaration_counts();
|
||||
assert!(counts.contains(&("menus".into(), 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_01_format() {
|
||||
let m = Manifest::parse(
|
||||
"id = \"hello\"\nname = \"Hello\"\nversion = \"0.1.0\"\nopenidea-api = \"0.1\"\nwasm = \"dist/plugin.wasm\"\n\
|
||||
permissions = [\"project-read\", \"editor-write\"]\n[[contributes.commands]]\nid = \"x\"\ntitle = \"X\"",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(m.legacy);
|
||||
assert_eq!(m.capabilities.required, vec![Capability::ProjectRead, Capability::EditorWrite]);
|
||||
assert_eq!(m.activation, vec![ActivationEvent::StartupFinished]);
|
||||
assert_eq!(m.publisher(), None);
|
||||
}
|
||||
|
||||
fn err(src: &str) -> String {
|
||||
Manifest::parse(src).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_manifests() {
|
||||
let base = |extra: &str| format!("id = \"ok\"\nname = \"N\"\nversion = \"1.0.0\"\n{extra}");
|
||||
assert!(err(&base("")).contains("engines.openidea"));
|
||||
assert!(err(&base("engines.openidea = \"^0.3\"")).contains("does not match"));
|
||||
assert!(err(&base("engines.openidea = \"^2\"")).contains("does not match"));
|
||||
assert!(err(&base("engines.openidea = \"^0.2\"")).contains("does not match"));
|
||||
assert!(err(&base("openidea-api = \"0.2\"")).contains("unsupported"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\npermissions = [\"project-read\"]")).contains("0.1 format"));
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\n[capabilities]\nrequired = [\"network\"]")).contains("unknown")
|
||||
);
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nwasm = \"../x.wasm\"")).contains("relative path"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nactivation = [\"on-command:x\"]")).contains("needs `wasm`"));
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-boot\"]"))
|
||||
.contains("unknown")
|
||||
);
|
||||
assert!(
|
||||
err(&base("engines.openidea = \"^1.0\"\nwasm = \"p.wasm\"\nactivation = [\"on-schedule:5\"]"))
|
||||
.contains("60 seconds")
|
||||
);
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\nbogus = 1")).contains("unknown field"));
|
||||
assert!(err(&base("engines.openidea = \"^1.0\"\ncontributes.commands = 3")).contains("table"));
|
||||
assert!(
|
||||
err("id = \"Bad\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
|
||||
);
|
||||
assert!(
|
||||
err("id = \"a..b\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"").contains("plugin id")
|
||||
);
|
||||
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nengines.openidea = \"^1.0\"").contains("version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids() {
|
||||
assert!(validate_plugin_id("openidea.material-icons").is_ok());
|
||||
assert!(validate_plugin_id("hello_2").is_ok());
|
||||
assert!(validate_plugin_id(".x").is_err());
|
||||
assert!(validate_item_id("word-count.v2_x", "command id").is_ok());
|
||||
assert!(validate_item_id("-x", "command id").is_err());
|
||||
assert!(validate_point_id("iconThemes").is_ok());
|
||||
assert!(validate_point_id("Icon").is_err());
|
||||
assert!(engine_compatible("1.0", false));
|
||||
assert!(engine_compatible(">=1.0, <2", false));
|
||||
assert!(!engine_compatible("^0.2", false), "0.x components don't run on 1.0");
|
||||
assert!(engine_compatible("0.1.3", true));
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
//! Plugin distribution: the package file, its
|
||||
//! signature, and the static marketplace index.
|
||||
//!
|
||||
//! - **Package** (`<id>-<version>.oiplugin`): a zip of the manifest, the files
|
||||
//! its declarations reference, the component and licence files, plus
|
||||
//! [`SIGNATURE_FILE`] when signed.
|
||||
//! - **Signature**: Ed25519 over `openidea-plugin/1\n<id>\n<version>\n<digest>`,
|
||||
//! `digest` being [`package_digest`] of the unpacked files. A signature
|
||||
//! proves who published the bytes; whether to trust that publisher is the
|
||||
//! index's call.
|
||||
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
|
||||
//! the published plugin versions, and revocations. A publisher owns the id
|
||||
//! prefix `<publisher>.`; a revoked key invalidates everything it signed, a
|
||||
//! revoked plugin version is disabled where it is installed. A plugin may be
|
||||
//! listed in several versions, each with the plugin API it needs: the IDE
|
||||
//! offers the newest one it can load.
|
||||
//! - **Key continuity**: an installed plugin updates silently only to a
|
||||
//! package signed with the same key, or with a key the installed one
|
||||
//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer
|
||||
//! is the user's call. An IDE can pin a publisher to keys it was built with
|
||||
//! ([`Index::pin_publisher`]): the index then can't replace them.
|
||||
//! - **Format**: fields are only ever added (IDEs ignore those they don't
|
||||
//! know); `format` grows only for changes older IDEs can't read, and they
|
||||
//! still apply such an index's revocations ([`Index::parse`]).
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use base64::Engine as _;
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const SIGNATURE_FILE: &str = "SIGNATURE.json";
|
||||
pub const PACKAGE_EXTENSION: &str = "oiplugin";
|
||||
pub const INDEX_FORMAT: u32 = 1;
|
||||
/// Limits when unpacking an untrusted package.
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_TOTAL: u64 = 512 << 20;
|
||||
|
||||
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
/// Content digest of a package: the manifest plus `files` (relative paths).
|
||||
pub fn package_digest(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
|
||||
let mut all = files.to_vec();
|
||||
all.push(PathBuf::from(MANIFEST_FILE));
|
||||
crate::ledger::package_hash(dir, &all)
|
||||
}
|
||||
|
||||
fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
|
||||
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
|
||||
}
|
||||
|
||||
fn endorsement_message(publisher: &str, key: &str) -> Vec<u8> {
|
||||
format!("openidea-key/1\n{publisher}\n{key}").into_bytes()
|
||||
}
|
||||
|
||||
/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of
|
||||
/// `message`; malformed keys and signatures are errors.
|
||||
fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result<bool, String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] =
|
||||
B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok())
|
||||
}
|
||||
|
||||
/// `SIGNATURE.json` of a signed package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageSignature {
|
||||
pub publisher: String,
|
||||
/// Ed25519 public key, base64.
|
||||
pub key: String,
|
||||
/// Ed25519 signature, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A publisher's key pair (the 32-byte seed, base64, in a file only the publisher has).
|
||||
pub struct PublisherKey(SigningKey);
|
||||
|
||||
impl PublisherKey {
|
||||
pub fn from_seed(seed: [u8; 32]) -> Self {
|
||||
Self(SigningKey::from_bytes(&seed))
|
||||
}
|
||||
|
||||
/// The seed as written by `openidea-plugin keygen`.
|
||||
pub fn from_base64(text: &str) -> Result<Self, String> {
|
||||
let bytes = B64.decode(text.trim()).map_err(|e| format!("key file: {e}"))?;
|
||||
let seed: [u8; 32] = bytes.try_into().map_err(|_| "key file: expected a 32-byte key".to_owned())?;
|
||||
Ok(Self::from_seed(seed))
|
||||
}
|
||||
|
||||
pub fn seed_base64(&self) -> String {
|
||||
B64.encode(self.0.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_base64(&self) -> String {
|
||||
B64.encode(self.0.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Vouches for `publisher`'s new key `key` (base64), the one that follows this one.
|
||||
pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement {
|
||||
let sig = self.0.sign(&endorsement_message(publisher, key));
|
||||
Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) }
|
||||
}
|
||||
|
||||
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
|
||||
let sig = self.0.sign(&signed_message(id, version, digest));
|
||||
PackageSignature {
|
||||
publisher: publisher.into(),
|
||||
key: self.public_base64(),
|
||||
signature: B64.encode(sig.to_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageSignature {
|
||||
/// Whether the signature is valid for `id`, `version` and `digest` (the
|
||||
/// key's validity is checked against an [`Index`]).
|
||||
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
|
||||
if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the signature does not match the package (it was changed after signing)".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the package `out` from `dir`: the manifest, `files`, and the signature if any.
|
||||
pub fn pack(dir: &Path, files: &[PathBuf], out: &Path, signature: Option<&PackageSignature>) -> Result<(), String> {
|
||||
let io = |e: std::io::Error| e.to_string();
|
||||
let file = std::fs::File::create(out).map_err(io)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let mut names: Vec<PathBuf> = std::iter::once(PathBuf::from(MANIFEST_FILE)).chain(files.iter().cloned()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for rel in names {
|
||||
let name = rel.to_str().ok_or("non UTF-8 file name")?.replace('\\', "/");
|
||||
zip.start_file(name, options).map_err(|e| e.to_string())?;
|
||||
let bytes = std::fs::read(dir.join(&rel)).map_err(|e| format!("{}: {e}", rel.display()))?;
|
||||
zip.write_all(&bytes).map_err(io)?;
|
||||
}
|
||||
if let Some(sig) = signature {
|
||||
zip.start_file(SIGNATURE_FILE, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(serde_json::to_string_pretty(sig).map_err(|e| e.to_string())?.as_bytes()).map_err(io)?;
|
||||
}
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What [`unpack`] wrote.
|
||||
#[derive(Debug)]
|
||||
pub struct Unpacked {
|
||||
/// Every file but the manifest and the signature, relative.
|
||||
pub files: Vec<PathBuf>,
|
||||
pub signature: Option<PackageSignature>,
|
||||
}
|
||||
|
||||
/// `name` as a relative path that stays inside the package.
|
||||
fn safe_path(name: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(name);
|
||||
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Archiver metadata at the top of an archive, not part of a package
|
||||
/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files).
|
||||
fn archiver_metadata(name: &str) -> bool {
|
||||
let top = name.split('/').next().unwrap_or_default();
|
||||
top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2)
|
||||
}
|
||||
|
||||
/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip
|
||||
/// of the package's contents) or in its only folder (a zip of the package
|
||||
/// folder). `None` when neither holds a manifest.
|
||||
fn package_prefix(names: &[String]) -> Option<String> {
|
||||
if names.iter().any(|n| n == MANIFEST_FILE) {
|
||||
return Some(String::new());
|
||||
}
|
||||
let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default());
|
||||
let top = tops.next()?;
|
||||
if tops.any(|t| t != top) {
|
||||
return None;
|
||||
}
|
||||
let prefix = format!("{top}/");
|
||||
names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix)
|
||||
}
|
||||
|
||||
/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package
|
||||
/// folder or of its contents) into the empty directory `dest`, refusing
|
||||
/// paths leaving it, links, and oversized contents.
|
||||
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
|
||||
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
|
||||
let mut zip =
|
||||
zip::ZipArchive::new(file).map_err(|e| format!("{}: not a plugin package ({e})", archive.display()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(format!("more than {MAX_ENTRIES} files"));
|
||||
}
|
||||
let mut names = Vec::with_capacity(zip.len());
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
if !entry.is_dir() {
|
||||
names.push(entry.name().map_err(|e| e.to_string())?.into_owned());
|
||||
}
|
||||
}
|
||||
// An IntelliJ plugin or any other zip: say what is missing rather than fail later.
|
||||
let prefix = package_prefix(&names).ok_or_else(|| {
|
||||
format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display())
|
||||
})?;
|
||||
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
|
||||
let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false);
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let full = entry.name().map_err(|e| e.to_string())?.into_owned();
|
||||
// Outside the package folder there is only archiver metadata (`package_prefix` checked).
|
||||
let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue };
|
||||
if archiver_metadata(&name) {
|
||||
continue;
|
||||
}
|
||||
if entry.is_symlink() {
|
||||
return Err(format!("{full}: links are not allowed in packages"));
|
||||
}
|
||||
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?;
|
||||
let mut bytes = Vec::new();
|
||||
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
total += bytes.len() as u64;
|
||||
if total > MAX_TOTAL {
|
||||
return Err(format!("the package unpacks to more than {} MiB", MAX_TOTAL >> 20));
|
||||
}
|
||||
if rel == Path::new(SIGNATURE_FILE) {
|
||||
signature = Some(serde_json::from_slice(&bytes).map_err(|e| format!("{SIGNATURE_FILE}: {e}"))?);
|
||||
continue;
|
||||
}
|
||||
let to = dest.join(&rel);
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&to, bytes).map_err(|e| e.to_string())?;
|
||||
if rel == Path::new(MANIFEST_FILE) {
|
||||
manifest = true;
|
||||
} else {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
if !manifest {
|
||||
return Err(format!("the package has no {MANIFEST_FILE}"));
|
||||
}
|
||||
files.sort();
|
||||
Ok(Unpacked { files, signature })
|
||||
}
|
||||
|
||||
// ---- index -------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Index {
|
||||
pub format: u32,
|
||||
#[serde(default)]
|
||||
pub publishers: Vec<Publisher>,
|
||||
#[serde(default)]
|
||||
pub plugins: Vec<IndexEntry>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revocation>,
|
||||
/// The marketplace moved: its index is at this URL (relative to this
|
||||
/// one, or absolute), and the IDE reads that one instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub moved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Publisher {
|
||||
/// Owns plugin ids `<id>.…`.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub keys: Vec<KeyEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyEntry {
|
||||
/// Ed25519 public key, base64.
|
||||
pub key: String,
|
||||
/// Packages signed with it are no longer trusted.
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
/// The publisher's earlier key vouching for this one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endorsement: Option<Endorsement>,
|
||||
}
|
||||
|
||||
impl KeyEntry {
|
||||
pub fn new(key: String) -> Self {
|
||||
Self { key, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A publisher's earlier key vouching for a newer one: plugins installed from
|
||||
/// packages signed with the earlier key update to ones signed with the newer
|
||||
/// key without asking the user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Endorsement {
|
||||
/// The endorsing key, base64.
|
||||
pub key: String,
|
||||
/// Its Ed25519 signature of `openidea-key/1\n<publisher>\n<endorsed key>`, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A published plugin version.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
pub publisher: String,
|
||||
/// The package, relative to the index (or an absolute URL).
|
||||
pub download: String,
|
||||
/// [`package_digest`] of its contents.
|
||||
pub digest: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
/// The plugin API versions it loads in (a semver requirement): its
|
||||
/// manifest's `engines.openidea` and the API its component was built for.
|
||||
/// Required: an entry without one is offered to no IDE.
|
||||
#[serde(default)]
|
||||
pub engine: String,
|
||||
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
}
|
||||
|
||||
impl IndexEntry {
|
||||
/// Whether this IDE can load the version.
|
||||
pub fn compatible(&self) -> bool {
|
||||
crate::engine_compatible(&self.engine, false)
|
||||
}
|
||||
|
||||
fn semver(&self) -> Option<semver::Version> {
|
||||
semver::Version::parse(&self.version).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `engines.openidea` of a manifest narrowed to the API its component was built
|
||||
/// for (`major.minor`, if it has code): the requirement an index lists.
|
||||
pub fn engine_requirement(manifest_engine: &str, built_for: Option<&semver::Version>) -> String {
|
||||
match built_for {
|
||||
Some(v) => format!("{manifest_engine}, ^{}.{}", v.major, v.minor),
|
||||
None => manifest_engine.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin (all versions when `versions` is empty) that must not run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Revocation {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub versions: Vec<String>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Parses an index. One in a newer format keeps what must reach every IDE —
|
||||
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
|
||||
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
|
||||
if format == u64::from(INDEX_FORMAT) {
|
||||
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
|
||||
}
|
||||
if format < u64::from(INDEX_FORMAT) {
|
||||
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
|
||||
}
|
||||
fn each<T: serde::de::DeserializeOwned>(value: &serde_json::Value, field: &str) -> Vec<T> {
|
||||
let items = value.get(field).and_then(serde_json::Value::as_array);
|
||||
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
|
||||
}
|
||||
Ok(Index {
|
||||
format: u32::try_from(format).unwrap_or(u32::MAX),
|
||||
publishers: each(&value, "publishers"),
|
||||
plugins: Vec::new(),
|
||||
revoked: each(&value, "revoked"),
|
||||
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Written in a format newer than this IDE reads: it lists no plugins.
|
||||
pub fn is_newer(&self) -> bool {
|
||||
self.format > INDEX_FORMAT
|
||||
}
|
||||
|
||||
/// The version of each plugin to offer, and whether this IDE can load it:
|
||||
/// the newest it can load, else the newest. Revoked versions are skipped;
|
||||
/// plugins come in the order they are first listed.
|
||||
pub fn offered(&self) -> Vec<(&IndexEntry, bool)> {
|
||||
let mut ids: Vec<&str> = Vec::new();
|
||||
for e in &self.plugins {
|
||||
if !ids.contains(&e.id.as_str()) {
|
||||
ids.push(&e.id);
|
||||
}
|
||||
}
|
||||
ids.into_iter()
|
||||
.filter_map(|id| {
|
||||
let versions: Vec<&IndexEntry> =
|
||||
self.plugins.iter().filter(|e| e.id == id && self.revocation(id, &e.version).is_none()).collect();
|
||||
let newest = |loadable: bool| {
|
||||
versions.iter().copied().filter(|e| !loadable || e.compatible()).max_by_key(|e| e.semver())
|
||||
};
|
||||
newest(true).map(|e| (e, true)).or_else(|| newest(false).map(|e| (e, false)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the versions no IDE would pick: revoked ones, and of each plugin
|
||||
/// all but the newest version per API requirement.
|
||||
pub fn prune_versions(&mut self) {
|
||||
let plugins = std::mem::take(&mut self.plugins);
|
||||
let mut kept: Vec<IndexEntry> = Vec::new();
|
||||
for e in plugins.into_iter().filter(|e| self.revocation(&e.id, &e.version).is_none()) {
|
||||
match kept.iter_mut().find(|k| k.id == e.id && k.engine == e.engine) {
|
||||
Some(k) if e.semver() > k.semver() => *k = e,
|
||||
Some(_) => {}
|
||||
None => kept.push(e),
|
||||
}
|
||||
}
|
||||
self.plugins = kept;
|
||||
}
|
||||
|
||||
/// The revocation that applies to `id` at `version`.
|
||||
pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> {
|
||||
self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version)))
|
||||
}
|
||||
|
||||
/// Whether `key` (base64) belongs to a publisher and was revoked.
|
||||
pub fn key_revoked(&self, key: &str) -> bool {
|
||||
self.publishers.iter().flat_map(|p| &p.keys).any(|k| k.key == key && k.revoked)
|
||||
}
|
||||
|
||||
/// The trusted publisher of a signed package: the signature is valid, its
|
||||
/// key is one of the publisher's and not revoked, and the publisher owns
|
||||
/// the plugin's id prefix.
|
||||
pub fn verify(&self, sig: &PackageSignature, id: &str, version: &str, digest: &str) -> Result<String, String> {
|
||||
let publisher = self
|
||||
.publishers
|
||||
.iter()
|
||||
.find(|p| p.id == sig.publisher)
|
||||
.ok_or_else(|| format!("unknown publisher {:?}", sig.publisher))?;
|
||||
let key = publisher
|
||||
.keys
|
||||
.iter()
|
||||
.find(|k| k.key == sig.key)
|
||||
.ok_or_else(|| format!("the key is not one of {}'s", publisher.name))?;
|
||||
if key.revoked {
|
||||
return Err(format!("{}'s signing key was revoked", publisher.name));
|
||||
}
|
||||
if !id.starts_with(&format!("{}.", publisher.id)) {
|
||||
return Err(format!("{} may only publish plugins named {}.*", publisher.name, publisher.id));
|
||||
}
|
||||
sig.check(id, version, digest)?;
|
||||
Ok(publisher.id.clone())
|
||||
}
|
||||
|
||||
/// Whether `to` is `from`, or a key of `publisher` that `from` endorsed,
|
||||
/// directly or through keys it endorsed. Endorsements by revoked keys don't
|
||||
/// count: whoever stole a key could vouch for their own.
|
||||
pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool {
|
||||
let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]);
|
||||
let mut key = to;
|
||||
// Each step moves to an earlier key; more steps than keys is a loop.
|
||||
for _ in 0..=keys.len() {
|
||||
if key == from {
|
||||
return true;
|
||||
}
|
||||
let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked);
|
||||
if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
key = &e.key;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Makes `roots` (base64) the keys `publisher` signs with, whatever this
|
||||
/// index says: another key it lists stays only when a root endorsed it (a
|
||||
/// rotation the roots vouched for). Revocations of the keys are kept.
|
||||
pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) {
|
||||
if roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.publishers.iter().position(|p| p.id == publisher) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() });
|
||||
self.publishers.len() - 1
|
||||
}
|
||||
};
|
||||
for root in roots {
|
||||
if !self.publishers[i].keys.iter().any(|k| &k.key == root) {
|
||||
self.publishers[i].keys.push(KeyEntry::new(root.clone()));
|
||||
}
|
||||
}
|
||||
let keys = self.publishers[i].keys.clone();
|
||||
let trusted = keys
|
||||
.into_iter()
|
||||
.filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key)))
|
||||
.map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k })
|
||||
.collect();
|
||||
self.publishers[i].keys = trusted;
|
||||
}
|
||||
|
||||
/// Replaces what this index lists for every publisher but `keep` with what
|
||||
/// `other` lists: its publishers, their plugins and their revocations
|
||||
/// (`other`'s downloads should already be absolute). `other` may only
|
||||
/// speak for its own publishers, and none of them may be in `keep`.
|
||||
pub fn include(&mut self, other: Index, keep: &[&str]) -> Result<(), String> {
|
||||
let mut theirs: Vec<&str> = Vec::new();
|
||||
for p in &other.publishers {
|
||||
if !publisher_id_ok(&p.id) {
|
||||
return Err(format!("invalid publisher id {:?}: use a-z, 0-9, '-' and '_'", p.id));
|
||||
}
|
||||
if keep.contains(&p.id.as_str()) {
|
||||
return Err(format!("may not list publisher {:?}", p.id));
|
||||
}
|
||||
if theirs.contains(&p.id.as_str()) {
|
||||
return Err(format!("publisher {:?} is listed twice", p.id));
|
||||
}
|
||||
theirs.push(&p.id);
|
||||
}
|
||||
let owned_by = |id: &str| theirs.iter().any(|p| owns(p, id));
|
||||
for e in &other.plugins {
|
||||
if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) {
|
||||
return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher));
|
||||
}
|
||||
if semver::VersionReq::parse(&e.engine).is_err() {
|
||||
return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version));
|
||||
}
|
||||
}
|
||||
if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) {
|
||||
return Err(format!("{}: revoked by an index that doesn't publish it", r.id));
|
||||
}
|
||||
let kept = |id: &str| keep.iter().any(|p| owns(p, id));
|
||||
self.publishers.retain(|p| keep.contains(&p.id.as_str()));
|
||||
self.plugins.retain(|e| keep.contains(&e.publisher.as_str()));
|
||||
self.revoked.retain(|r| kept(&r.id));
|
||||
self.publishers.extend(other.publishers);
|
||||
self.plugins.extend(other.plugins);
|
||||
self.revoked.extend(other.revoked);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a package signed `new` (`None`: unsigned) may replace an installed
|
||||
/// one signed `installed` without asking the user: signed with the same key,
|
||||
/// or with one the installed key endorsed (per `index`). An unsigned installed
|
||||
/// copy pins nothing.
|
||||
pub fn signer_continues(
|
||||
index: Option<&Index>,
|
||||
installed: Option<&PackageSignature>,
|
||||
new: Option<&PackageSignature>,
|
||||
) -> bool {
|
||||
match (installed, new) {
|
||||
(None, _) => true,
|
||||
(Some(_), None) => false,
|
||||
(Some(old), Some(new)) => {
|
||||
old.publisher == new.publisher
|
||||
&& (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the plugin `id` is in `publisher`'s namespace.
|
||||
fn owns(publisher: &str, id: &str) -> bool {
|
||||
id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// A publisher id is a single segment of a plugin id.
|
||||
pub fn publisher_id_ok(id: &str) -> bool {
|
||||
!id.contains('.') && crate::validate_plugin_id(id).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn package(dir: &Path) -> Vec<PathBuf> {
|
||||
std::fs::create_dir_all(dir.join("dist")).unwrap();
|
||||
std::fs::write(dir.join(MANIFEST_FILE), "id = \"acme.tools\"\nversion = \"1.0.0\"").unwrap();
|
||||
std::fs::write(dir.join("dist/plugin.wasm"), b"\0asm").unwrap();
|
||||
vec![PathBuf::from("dist/plugin.wasm")]
|
||||
}
|
||||
|
||||
fn index(key: &PublisherKey) -> Index {
|
||||
Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry::new(key.public_base64())],
|
||||
}],
|
||||
plugins: Vec::new(),
|
||||
revoked: vec![Revocation {
|
||||
id: "acme.bad".into(),
|
||||
versions: vec!["1.0.0".into()],
|
||||
reason: "malware".into(),
|
||||
}],
|
||||
moved: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_packages_round_trip_and_tampering_is_caught() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let src = tmp.path().join("src");
|
||||
let files = package(&src);
|
||||
let digest = package_digest(&src, &files).unwrap();
|
||||
let key = PublisherKey::from_seed([7; 32]);
|
||||
let sig = key.sign("acme", "acme.tools", "1.0.0", &digest);
|
||||
let out = tmp.path().join("acme.tools-1.0.0.oiplugin");
|
||||
pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
|
||||
let dest = tmp.path().join("dest");
|
||||
let unpacked = unpack(&out, &dest).unwrap();
|
||||
assert_eq!(unpacked.files, files);
|
||||
assert_eq!(package_digest(&dest, &unpacked.files).unwrap(), digest);
|
||||
let index = index(&key);
|
||||
assert_eq!(index.verify(unpacked.signature.as_ref().unwrap(), "acme.tools", "1.0.0", &digest).unwrap(), "acme");
|
||||
|
||||
// Changed bytes, another id, someone else's prefix, a revoked key.
|
||||
assert!(index.verify(&sig, "acme.tools", "1.0.0", "0000").is_err());
|
||||
assert!(index.verify(&sig, "acme.tools", "1.0.1", &digest).is_err());
|
||||
let other = PublisherKey::from_seed([9; 32]).sign("acme", "acme.tools", "1.0.0", &digest);
|
||||
assert!(index.verify(&other, "acme.tools", "1.0.0", &digest).unwrap_err().contains("not one of"));
|
||||
let foreign = key.sign("acme", "evil.tools", "1.0.0", &digest);
|
||||
assert!(index.verify(&foreign, "evil.tools", "1.0.0", &digest).unwrap_err().contains("acme.*"));
|
||||
let mut revoked = index.clone();
|
||||
revoked.publishers[0].keys[0].revoked = true;
|
||||
assert!(revoked.verify(&sig, "acme.tools", "1.0.0", &digest).unwrap_err().contains("revoked"));
|
||||
assert!(revoked.key_revoked(&sig.key));
|
||||
assert_eq!(index.revocation("acme.bad", "1.0.0").unwrap().reason, "malware");
|
||||
assert!(index.revocation("acme.bad", "1.0.1").is_none());
|
||||
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
|
||||
}
|
||||
|
||||
fn entry(id: &str, publisher: &str) -> IndexEntry {
|
||||
IndexEntry {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
description: String::new(),
|
||||
authors: Vec::new(),
|
||||
publisher: publisher.into(),
|
||||
download: format!("https://example.com/{id}.oiplugin"),
|
||||
digest: "d".into(),
|
||||
capabilities: Vec::new(),
|
||||
engine: "^1.0".into(),
|
||||
categories: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn version(id: &str, version: &str, engine: &str) -> IndexEntry {
|
||||
IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ide_is_offered_the_newest_version_it_can_load() {
|
||||
let host = crate::ENGINE_VERSION;
|
||||
let newer = semver::Version::parse(host).unwrap().major + 1;
|
||||
let mut index = Index {
|
||||
format: INDEX_FORMAT,
|
||||
plugins: vec![
|
||||
version("acme.a", "1.0.0", &format!("^{host}")),
|
||||
version("acme.a", "1.2.0", &format!("^{host}")),
|
||||
version("acme.a", "2.0.0", &format!("^{newer}.0")),
|
||||
version("acme.b", "0.1.0", &format!("^{newer}.0")),
|
||||
version("acme.b", "0.2.0", &format!("^{newer}.1")),
|
||||
version("acme.c", "1.0.0", ""),
|
||||
version("acme.c", "1.1.0", ""),
|
||||
],
|
||||
revoked: vec![Revocation { id: "acme.c".into(), versions: vec!["1.1.0".into()], reason: "r".into() }],
|
||||
..Index::default()
|
||||
};
|
||||
let offered = |index: &Index| {
|
||||
index.offered().into_iter().map(|(e, ok)| (e.id.clone(), e.version.clone(), ok)).collect::<Vec<_>>()
|
||||
};
|
||||
let expected = [
|
||||
("acme.a".to_owned(), "1.2.0".to_owned(), true),
|
||||
("acme.b".to_owned(), "0.2.0".to_owned(), false),
|
||||
// No `engine`: listed, but no IDE installs it.
|
||||
("acme.c".to_owned(), "1.0.0".to_owned(), false),
|
||||
];
|
||||
assert_eq!(offered(&index), expected);
|
||||
|
||||
// Pruning keeps the newest version per requirement, which changes nothing offered.
|
||||
index.prune_versions();
|
||||
let left: Vec<_> = index.plugins.iter().map(|e| format!("{} {}", e.id, e.version)).collect();
|
||||
assert_eq!(left, ["acme.a 1.2.0", "acme.a 2.0.0", "acme.b 0.1.0", "acme.b 0.2.0", "acme.c 1.0.0"]);
|
||||
assert_eq!(offered(&index), expected);
|
||||
|
||||
let built_for = semver::Version::new(1, 3, 0);
|
||||
assert_eq!(engine_requirement("^1.0", Some(&built_for)), "^1.0, ^1.3");
|
||||
assert_eq!(engine_requirement("^1.0", None), "^1.0");
|
||||
assert!(semver::VersionReq::parse(&engine_requirement("^1.0", Some(&built_for))).is_ok());
|
||||
}
|
||||
|
||||
fn publisher(id: &str) -> Publisher {
|
||||
Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] }
|
||||
}
|
||||
|
||||
fn revocation(id: &str) -> Revocation {
|
||||
Revocation { id: id.into(), versions: Vec::new(), reason: "r".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn including_an_index_replaces_everything_but_the_kept_publishers() {
|
||||
let mut official = Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![publisher("openidea"), publisher("gone")],
|
||||
plugins: vec![entry("openidea.a", "openidea"), entry("gone.x", "gone")],
|
||||
revoked: vec![revocation("openidea.old"), revocation("gone.x")],
|
||||
moved: None,
|
||||
};
|
||||
let community = Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![publisher("alice")],
|
||||
plugins: vec![entry("alice.arthas", "alice")],
|
||||
revoked: vec![revocation("alice.bad")],
|
||||
moved: None,
|
||||
};
|
||||
official.include(community.clone(), &["openidea"]).unwrap();
|
||||
let ids = |v: &[IndexEntry]| v.iter().map(|e| e.id.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(official.publishers.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["openidea", "alice"]);
|
||||
assert_eq!(ids(&official.plugins), ["openidea.a", "alice.arthas"]);
|
||||
assert_eq!(official.revoked.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), ["openidea.old", "alice.bad"]);
|
||||
|
||||
// Including again is idempotent.
|
||||
let once = official.clone();
|
||||
official.include(community.clone(), &["openidea"]).unwrap();
|
||||
assert_eq!(official, once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_included_index_speaks_only_for_its_own_publishers() {
|
||||
let base = Index { format: INDEX_FORMAT, publishers: vec![publisher("openidea")], ..Index::default() };
|
||||
let with = |publishers: Vec<Publisher>, plugins: Vec<IndexEntry>, revoked: Vec<Revocation>| Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers,
|
||||
plugins,
|
||||
revoked,
|
||||
moved: None,
|
||||
};
|
||||
let cases = [
|
||||
(with(vec![publisher("openidea")], vec![], vec![]), "may not list"),
|
||||
(with(vec![publisher("alice"), publisher("alice")], vec![], vec![]), "listed twice"),
|
||||
(with(vec![publisher("openidea.x")], vec![], vec![]), "invalid publisher"),
|
||||
(with(vec![publisher("alice")], vec![entry("openidea.a", "alice")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"),
|
||||
(
|
||||
with(
|
||||
vec![publisher("alice")],
|
||||
vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }],
|
||||
vec![],
|
||||
),
|
||||
"`engine`",
|
||||
),
|
||||
];
|
||||
for (other, error) in cases {
|
||||
let mut index = base.clone();
|
||||
let err = index.include(other, &["openidea"]).unwrap_err();
|
||||
assert!(err.contains(error), "{err}");
|
||||
assert_eq!(index, base);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacking_refuses_paths_leaving_the_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let out = tmp.path().join("evil.oiplugin");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&out).unwrap());
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST_FILE, o).unwrap();
|
||||
zip.write_all(b"id = \"x\"").unwrap();
|
||||
zip.start_file("../outside.txt", o).unwrap();
|
||||
zip.write_all(b"x").unwrap();
|
||||
zip.finish().unwrap();
|
||||
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
|
||||
assert!(err.contains("invalid path"), "{err}");
|
||||
assert!(!tmp.path().join("outside.txt").exists());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer());
|
||||
}
|
||||
|
||||
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, body) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manifest = "id = \"acme.tools\"";
|
||||
let at_root = tmp.path().join("root.zip");
|
||||
zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]);
|
||||
let got = unpack(&at_root, &tmp.path().join("a")).unwrap();
|
||||
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
|
||||
|
||||
// What zipping the folder in Finder makes: the folder plus `__MACOSX`.
|
||||
let folder = tmp.path().join("folder.zip");
|
||||
zip_of(
|
||||
&folder,
|
||||
&[
|
||||
("acme-tools/plugin.toml", manifest),
|
||||
("acme-tools/dist/plugin.wasm", "w"),
|
||||
("__MACOSX/acme-tools/._plugin.toml", "x"),
|
||||
],
|
||||
);
|
||||
let dest = tmp.path().join("b");
|
||||
let got = unpack(&folder, &dest).unwrap();
|
||||
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
|
||||
assert!(dest.join(MANIFEST_FILE).is_file());
|
||||
assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists());
|
||||
|
||||
// Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package.
|
||||
for (name, entries) in [
|
||||
("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]),
|
||||
("deep.zip", &[("a/b/plugin.toml", manifest)][..]),
|
||||
("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]),
|
||||
] {
|
||||
let path = tmp.path().join(name);
|
||||
zip_of(&path, entries);
|
||||
let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err();
|
||||
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_index_still_revokes_and_moves() {
|
||||
let newer = format!(
|
||||
r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed",
|
||||
"publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}],
|
||||
"revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}],
|
||||
"moved": "https://example.com/next.json"}}"#,
|
||||
INDEX_FORMAT + 1
|
||||
);
|
||||
let index = Index::parse(&newer).unwrap();
|
||||
assert!(index.is_newer());
|
||||
assert!(index.plugins.is_empty() && index.offered().is_empty());
|
||||
assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware");
|
||||
assert!(index.key_revoked("k1"));
|
||||
assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json"));
|
||||
// Fields this IDE doesn't know are ignored in its own format.
|
||||
let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#);
|
||||
assert!(!Index::parse(¤t).unwrap().is_newer());
|
||||
assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported"));
|
||||
assert!(Index::parse(r#"{"plugins": []}"#).is_err());
|
||||
}
|
||||
|
||||
fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec<KeyEntry> {
|
||||
&mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_keys_continue_through_endorsements() {
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let mut index = index(&k1);
|
||||
let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry {
|
||||
endorsement: Some(by.endorse("acme", &key.public_base64())),
|
||||
..KeyEntry::new(key.public_base64())
|
||||
};
|
||||
keys(&mut index, "acme").push(endorsed(&k1, &k2));
|
||||
keys(&mut index, "acme").push(endorsed(&k2, &k3));
|
||||
let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64());
|
||||
assert!(index.succeeds("acme", &p1, &p1));
|
||||
assert!(index.succeeds("acme", &p1, &p3), "through k2");
|
||||
assert!(!index.succeeds("acme", &p3, &p1), "not backwards");
|
||||
assert!(!index.succeeds("other", &p1, &p2));
|
||||
// An endorsement for another publisher, or by a revoked key, doesn't count.
|
||||
let mut foreign = index.clone();
|
||||
keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2));
|
||||
assert!(!foreign.succeeds("acme", &p1, &p2));
|
||||
let mut stolen = index.clone();
|
||||
keys(&mut stolen, "acme")[0].revoked = true;
|
||||
assert!(!stolen.succeeds("acme", &p1, &p2));
|
||||
|
||||
let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d");
|
||||
assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3))));
|
||||
assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1))));
|
||||
assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch");
|
||||
let stranger = PublisherKey::from_seed([4; 32]);
|
||||
assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger))));
|
||||
assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned");
|
||||
assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_publishers_keep_their_built_in_keys() {
|
||||
let (root, next, intruder) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32]));
|
||||
let mut index = index(&intruder);
|
||||
index.publishers[0].id = "openidea".into();
|
||||
keys(&mut index, "openidea").push(KeyEntry {
|
||||
endorsement: Some(root.endorse("openidea", &next.public_base64())),
|
||||
..KeyEntry::new(next.public_base64())
|
||||
});
|
||||
let roots = [root.public_base64()];
|
||||
let listed = |index: &Index| -> Vec<(String, bool)> {
|
||||
let mut index = index.clone();
|
||||
index.pin_publisher("openidea", "OpenIDEA", &roots);
|
||||
keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect()
|
||||
};
|
||||
// The intruder's key goes, the successor the root endorsed stays, the root is added.
|
||||
assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]);
|
||||
let mut pinned = index.clone();
|
||||
pinned.pin_publisher("openidea", "OpenIDEA", &roots);
|
||||
let digest = "d";
|
||||
let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest);
|
||||
assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err());
|
||||
assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok());
|
||||
// A revoked root stays revoked, and what it endorsed is no longer trusted.
|
||||
keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) });
|
||||
assert_eq!(listed(&index), [(root.public_base64(), true)]);
|
||||
|
||||
// A publisher missing from the index is added with its roots.
|
||||
let mut empty = Index { format: INDEX_FORMAT, ..Index::default() };
|
||||
empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]);
|
||||
assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea");
|
||||
// No roots: the index decides.
|
||||
let mut open = index.clone();
|
||||
open.pin_publisher("openidea", "OpenIDEA", &[]);
|
||||
assert_eq!(open, index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The extension point contract (plan 4.1).
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::manifest::{Manifest, check_relative};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Where a contribution takes effect (plan 3, placement rules).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Placement {
|
||||
/// Projected to the client (menus, keybindings, themes, icons).
|
||||
Ui,
|
||||
/// Takes effect on the machine the project is on (languages, LSP, run configurations).
|
||||
Workspace,
|
||||
}
|
||||
|
||||
/// The package a declaration comes from, as extension points see it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Package {
|
||||
pub manifest: Arc<Manifest>,
|
||||
/// Package directory.
|
||||
pub dir: PathBuf,
|
||||
/// Hash of the package's files when they never change (an installed
|
||||
/// copy): validated declarations are then reused from the
|
||||
/// [`ContributionIndex`](crate::ContributionIndex).
|
||||
pub digest: Option<Arc<str>>,
|
||||
}
|
||||
|
||||
/// Largest single package file an extension point reads.
|
||||
pub const MAX_PACKAGE_FILE: u64 = 8 << 20;
|
||||
|
||||
impl Package {
|
||||
pub fn new(manifest: Manifest, dir: PathBuf) -> Self {
|
||||
Self { manifest: Arc::new(manifest), dir, digest: None }
|
||||
}
|
||||
|
||||
pub fn with_digest(mut self, digest: &str) -> Self {
|
||||
self.digest = Some(digest.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.manifest.id
|
||||
}
|
||||
|
||||
/// Resolves a package-relative path, refusing anything that leaves the
|
||||
/// package (`..`, absolute paths, symlinks pointing outside).
|
||||
pub fn resolve(&self, rel: &Path) -> Result<PathBuf, String> {
|
||||
check_relative(rel, "path")?;
|
||||
let joined = self.dir.join(rel);
|
||||
let real = joined.canonicalize().map_err(|_| format!("{}: no such file in the plugin", rel.display()))?;
|
||||
let root = self.dir.canonicalize().map_err(|e| format!("plugin directory: {e}"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{}: points outside the plugin", rel.display()));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
pub fn read_bytes(&self, rel: &Path) -> Result<Vec<u8>, String> {
|
||||
let real = self.resolve(rel)?;
|
||||
let meta = std::fs::metadata(&real).map_err(|e| format!("{}: {e}", rel.display()))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", rel.display()));
|
||||
}
|
||||
if meta.len() > MAX_PACKAGE_FILE {
|
||||
return Err(format!("{}: larger than {} MiB", rel.display(), MAX_PACKAGE_FILE >> 20));
|
||||
}
|
||||
std::fs::read(&real).map_err(|e| format!("{}: {e}", rel.display()))
|
||||
}
|
||||
|
||||
pub fn read_text(&self, rel: &Path) -> Result<String, String> {
|
||||
String::from_utf8(self.read_bytes(rel)?).map_err(|_| format!("{}: not UTF-8 text", rel.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Undoes one committed contribution. Dropping without [`dispose`](Self::dispose) also disposes.
|
||||
#[must_use = "dropping a Disposable undoes the contribution"]
|
||||
pub struct Disposable(Option<Box<dyn FnOnce() + Send>>);
|
||||
|
||||
impl Disposable {
|
||||
pub fn new(f: impl FnOnce() + Send + 'static) -> Self {
|
||||
Self(Some(Box::new(f)))
|
||||
}
|
||||
|
||||
/// A contribution with nothing to undo.
|
||||
pub fn noop() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
|
||||
pub fn dispose(mut self) {
|
||||
if let Some(f) = self.0.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Disposable {
|
||||
fn drop(&mut self) {
|
||||
if let Some(f) = self.0.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Disposable {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Disposable")
|
||||
}
|
||||
}
|
||||
|
||||
pub type Commit = Box<dyn FnOnce() -> Result<Disposable, String> + Send>;
|
||||
|
||||
/// A validated declaration, ready to take effect.
|
||||
pub struct Prepared {
|
||||
/// Capabilities this declaration needs (a language with an LSP → `process:<binary>`).
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Package files the declaration references (copied on install).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Identity within the point: a later declaration with the same key replaces
|
||||
/// the earlier one of the same plugin (re-registering a command).
|
||||
pub key: Option<String>,
|
||||
/// User-facing one-liner for the plugin details page ("Insert Date").
|
||||
pub label: String,
|
||||
/// Makes it take effect. Must not fail for reasons `prepare` could have checked.
|
||||
pub commit: Commit,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub fn new(label: impl Into<String>, commit: impl FnOnce() -> Result<Disposable, String> + Send + 'static) -> Self {
|
||||
Self { capabilities: Vec::new(), files: Vec::new(), key: None, label: label.into(), commit: Box::new(commit) }
|
||||
}
|
||||
|
||||
pub fn key(mut self, key: impl Into<String>) -> Self {
|
||||
self.key = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.files.push(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn capability(mut self, cap: Capability) -> Self {
|
||||
if !self.capabilities.contains(&cap) {
|
||||
self.capabilities.push(cap);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Prepared {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Prepared").field("key", &self.key).field("label", &self.label).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind of contribution (`contributes.<id>` in the manifest). Implemented by
|
||||
/// the crate that owns the feature; registered once in an [`crate::ExtensionRegistry`].
|
||||
pub trait ExtensionPoint: Send + Sync {
|
||||
/// `"languages"`, `"iconThemes"`, …
|
||||
fn id(&self) -> &'static str;
|
||||
|
||||
fn placement(&self) -> Placement;
|
||||
|
||||
/// Parses and validates one declaration without side effects, and works
|
||||
/// out the capabilities it needs. `Ui` points validate completely here: the
|
||||
/// client's projection is not allowed to fail.
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String>;
|
||||
}
|
||||
|
||||
/// Deserializes a declaration with serde, prefixing errors with the point id.
|
||||
pub fn decode<T: serde::de::DeserializeOwned>(point: &str, decl: toml::Value) -> Result<T, String> {
|
||||
decl.try_into().map_err(|e: toml::de::Error| format!("contributes.{point}: {}", e.message()))
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Extension points plus the contributions committed to them, accounted per plugin.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A contribution as the plugin details page lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContributionInfo {
|
||||
pub point: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
point: &'static str,
|
||||
key: Option<String>,
|
||||
label: String,
|
||||
disposable: Disposable,
|
||||
}
|
||||
|
||||
/// Every declaration of a package, validated; nothing has taken effect yet.
|
||||
pub struct PreparedSet {
|
||||
pub plugin: String,
|
||||
/// Union of what the declarations need (beyond the manifest's own list).
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Package files the declarations reference.
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Declarations for extension points this host does not know (skipped).
|
||||
pub warnings: Vec<String>,
|
||||
items: Vec<(&'static str, Prepared)>,
|
||||
}
|
||||
|
||||
impl PreparedSet {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreparedSet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PreparedSet")
|
||||
.field("plugin", &self.plugin)
|
||||
.field("items", &self.items.len())
|
||||
.field("warnings", &self.warnings)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[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>>>,
|
||||
}
|
||||
|
||||
impl ExtensionRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds an extension point. Ids are unique.
|
||||
pub fn register(&self, point: Arc<dyn ExtensionPoint>) -> Result<(), String> {
|
||||
let id = point.id();
|
||||
crate::manifest::validate_point_id(id)?;
|
||||
let mut points = self.points.write();
|
||||
if points.contains_key(id) {
|
||||
return Err(format!("extension point {id:?} is already registered"));
|
||||
}
|
||||
points.insert(id, point);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn point_ids(&self) -> Vec<&'static str> {
|
||||
self.points.read().keys().copied().collect()
|
||||
}
|
||||
|
||||
pub fn placement(&self, point: &str) -> Option<Placement> {
|
||||
self.points.read().get(point).map(|p| p.placement())
|
||||
}
|
||||
|
||||
fn point(&self, id: &str) -> Option<Arc<dyn ExtensionPoint>> {
|
||||
self.points.read().get(id).cloned()
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub fn prepare(&self, pkg: &Package) -> Result<PreparedSet, String> {
|
||||
let mut set = PreparedSet {
|
||||
plugin: pkg.id().to_owned(),
|
||||
capabilities: Vec::new(),
|
||||
files: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
items: Vec::new(),
|
||||
};
|
||||
for point_id in pkg.manifest.contributes.keys() {
|
||||
let Some(point) = self.point(point_id) else {
|
||||
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;
|
||||
};
|
||||
for (i, decl) in pkg.manifest.declarations(point_id).into_iter().enumerate() {
|
||||
let prepared = point
|
||||
.prepare(pkg, decl)
|
||||
.map_err(|e| format!("contributes.{point_id}[{i}]: {}", strip_prefix(&e, point_id)))?;
|
||||
for cap in &prepared.capabilities {
|
||||
if !set.capabilities.contains(cap) {
|
||||
set.capabilities.push(cap.clone());
|
||||
}
|
||||
}
|
||||
for f in &prepared.files {
|
||||
if !set.files.contains(f) {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Makes a prepared set take effect, all or nothing: if one commit fails,
|
||||
/// the ones before it are disposed in reverse order.
|
||||
pub fn commit(&self, set: PreparedSet) -> Result<(), String> {
|
||||
let mut committed: Vec<Entry> = Vec::with_capacity(set.items.len());
|
||||
for (point, prepared) in set.items {
|
||||
let Prepared { key, label, commit, .. } = prepared;
|
||||
match commit() {
|
||||
Ok(disposable) => committed.push(Entry { point, key, label, disposable }),
|
||||
Err(e) => {
|
||||
tracing::error!(plugin = %set.plugin, "contributes.{point}: commit failed: {e}");
|
||||
while let Some(entry) = committed.pop() {
|
||||
entry.disposable.dispose();
|
||||
}
|
||||
return Err(format!("contributes.{point}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let replaced = {
|
||||
let mut entries = self.entries.lock();
|
||||
let list = entries.entry(set.plugin).or_default();
|
||||
let mut replaced = Vec::new();
|
||||
for new in &committed {
|
||||
if let Some(key) = &new.key
|
||||
&& let Some(pos) = list.iter().position(|e| e.point == new.point && e.key.as_ref() == Some(key))
|
||||
{
|
||||
replaced.push(list.remove(pos));
|
||||
}
|
||||
}
|
||||
list.extend(committed);
|
||||
replaced
|
||||
};
|
||||
for old in replaced.into_iter().rev() {
|
||||
old.disposable.dispose();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraws everything `plugin` contributed, newest first.
|
||||
pub fn dispose_plugin(&self, plugin: &str) -> bool {
|
||||
let list = self.entries.lock().remove(plugin).unwrap_or_default();
|
||||
let any = !list.is_empty();
|
||||
for entry in list.into_iter().rev() {
|
||||
entry.disposable.dispose();
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
pub fn contributions(&self, plugin: &str) -> Vec<ContributionInfo> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(plugin)
|
||||
.map(|list| {
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How many contributions `plugin` has at `point`.
|
||||
pub fn count(&self, plugin: &str, point: &str) -> usize {
|
||||
self.entries.lock().get(plugin).map_or(0, |list| list.iter().filter(|e| e.point == point).count())
|
||||
}
|
||||
|
||||
/// Plugins with at least one contribution.
|
||||
pub fn plugins(&self) -> Vec<String> {
|
||||
self.entries.lock().iter().filter(|(_, l)| !l.is_empty()).map(|(id, _)| id.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
|
||||
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Records live items in a shared list; `fail = true` makes commit fail.
|
||||
struct Recording {
|
||||
id: &'static str,
|
||||
live: Arc<Mutex<Vec<String>>>,
|
||||
commits: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ExtensionPoint for Recording {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
fn prepare(&self, _pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let name = decl.get("name").and_then(|v| v.as_str()).ok_or("missing name")?.to_owned();
|
||||
let fail = decl.get("fail").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let (live, commits) = (self.live.clone(), self.commits.clone());
|
||||
let label = name.clone();
|
||||
let mut p = Prepared::new(label, move || {
|
||||
commits.fetch_add(1, Ordering::SeqCst);
|
||||
if fail {
|
||||
return Err("boom".into());
|
||||
}
|
||||
live.lock().push(name.clone());
|
||||
Ok(Disposable::new(move || live.lock().retain(|n| *n != name)))
|
||||
});
|
||||
if let Some(f) = decl.get("file").and_then(|v| v.as_str()) {
|
||||
p = p.file(f);
|
||||
}
|
||||
if decl.get("key").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
p = p.key("k");
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (ExtensionRegistry, Arc<Mutex<Vec<String>>>) {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let live = Arc::new(Mutex::new(Vec::new()));
|
||||
let commits = Arc::new(AtomicUsize::new(0));
|
||||
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: commits.clone() })).unwrap();
|
||||
reg.register(Arc::new(Recording { id: "others", live: live.clone(), commits })).unwrap();
|
||||
(reg, live)
|
||||
}
|
||||
|
||||
fn pkg(contributes: &str) -> Package {
|
||||
let src = format!("id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{contributes}");
|
||||
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_and_disposes_per_plugin() {
|
||||
let (reg, live) = setup();
|
||||
assert!(
|
||||
reg.register(Arc::new(Recording { id: "things", live: live.clone(), commits: Default::default() }))
|
||||
.is_err()
|
||||
);
|
||||
let p = pkg(
|
||||
"[[contributes.things]]\nname = \"a\"\nfile = \"x.json\"\n[[contributes.things]]\nname = \"b\"\n[contributes.others]\nname = \"c\"",
|
||||
);
|
||||
let set = reg.prepare(&p).unwrap();
|
||||
assert_eq!(set.len(), 3);
|
||||
assert_eq!(set.files, [PathBuf::from("x.json")]);
|
||||
assert!(live.lock().is_empty(), "prepare has no side effects");
|
||||
reg.commit(set).unwrap();
|
||||
assert_eq!(*live.lock(), ["c", "a", "b"]);
|
||||
assert_eq!(reg.count("p", "things"), 2);
|
||||
assert_eq!(reg.contributions("p").len(), 3);
|
||||
assert!(reg.dispose_plugin("p"));
|
||||
assert!(live.lock().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_commit_rolls_back() {
|
||||
let (reg, live) = setup();
|
||||
let p = pkg("[[contributes.things]]\nname = \"a\"\n[[contributes.things]]\nname = \"b\"\nfail = true");
|
||||
let err = reg.commit(reg.prepare(&p).unwrap()).unwrap_err();
|
||||
assert!(err.contains("boom"));
|
||||
assert!(live.lock().is_empty(), "a was undone");
|
||||
assert!(reg.contributions("p").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
|
||||
let (reg, _) = setup();
|
||||
let err = reg.prepare(&pkg("[[contributes.things]]\nnope = 1")).unwrap_err();
|
||||
assert_eq!(err, "contributes.things[0]: missing name");
|
||||
let set =
|
||||
reg.prepare(&pkg("[[contributes.futureThing]]\nx = 1\n[[contributes.things]]\nname = \"a\"")).unwrap();
|
||||
assert_eq!(set.len(), 1);
|
||||
assert_eq!(set.warnings.len(), 1);
|
||||
assert!(set.warnings[0].contains("futureThing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! UI contributions: validated declarations projected to the client.
|
||||
//!
|
||||
//! A `Ui` extension point validates a declaration completely in Rust and
|
||||
//! commits it as JSON into the [`UiStore`]; the client receives the store's
|
||||
//! snapshot and projects each item onto an existing `register*` function
|
||||
//! (`defineUiPoint` in `src/features/plugins/projection.ts`). Large payloads
|
||||
//! (icon SVGs) go into the store's assets and are fetched on demand.
|
||||
|
||||
use crate::index::ContributionIndex;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// One projected declaration.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UiContribution {
|
||||
pub plugin_id: String,
|
||||
/// Extension point id (`commands`, `iconThemes`, …).
|
||||
pub point: String,
|
||||
/// Stable within (plugin, point): the client keys its registrations on it.
|
||||
pub key: String,
|
||||
/// The validated declaration, shaped by the point.
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub decl: serde_json::Value,
|
||||
}
|
||||
|
||||
struct Item {
|
||||
seq: u64,
|
||||
contribution: UiContribution,
|
||||
}
|
||||
|
||||
/// Committed UI contributions of all plugins.
|
||||
#[derive(Default)]
|
||||
pub struct UiStore {
|
||||
items: RwLock<Vec<Item>>,
|
||||
/// (plugin, asset id) → content.
|
||||
assets: RwLock<BTreeMap<(String, String), Arc<str>>>,
|
||||
seq: AtomicU64,
|
||||
revision: AtomicU64,
|
||||
index: RwLock<Option<Arc<ContributionIndex>>>,
|
||||
}
|
||||
|
||||
impl UiStore {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Adds an item; the returned disposable removes it.
|
||||
pub fn insert(self: &Arc<Self>, contribution: UiContribution) -> Disposable {
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push(Item { seq, contribution });
|
||||
self.revision.fetch_add(1, Ordering::Release);
|
||||
let store = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(store) = store.upgrade() {
|
||||
store.items.write().retain(|i| i.seq != seq);
|
||||
store.revision.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Stores assets of a plugin (ids are unique per plugin; later ones replace).
|
||||
pub fn insert_assets(self: &Arc<Self>, plugin: &str, assets: Vec<(String, String)>) -> Disposable {
|
||||
let keys: Vec<(String, String)> = assets.iter().map(|(id, _)| (plugin.to_owned(), id.clone())).collect();
|
||||
{
|
||||
let mut map = self.assets.write();
|
||||
for (id, content) in assets {
|
||||
map.insert((plugin.to_owned(), id), Arc::from(content));
|
||||
}
|
||||
}
|
||||
let store = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(store) = store.upgrade() {
|
||||
let mut map = store.assets.write();
|
||||
for k in keys {
|
||||
map.remove(&k);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn asset(&self, plugin: &str, id: &str) -> Option<Arc<str>> {
|
||||
self.assets.read().get(&(plugin.to_owned(), id.to_owned())).cloned()
|
||||
}
|
||||
|
||||
/// Assets of `plugin` whose id starts with `prefix`.
|
||||
pub fn assets_with_prefix(&self, plugin: &str, prefix: &str) -> Vec<(String, Arc<str>)> {
|
||||
self.assets
|
||||
.read()
|
||||
.range((plugin.to_owned(), prefix.to_owned())..)
|
||||
.take_while(|((p, id), _)| p == plugin && id.starts_with(prefix))
|
||||
.map(|((_, id), v)| (id.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where [`UiPoint`]s keep validated declarations of installed packages.
|
||||
pub fn set_index(&self, index: Option<Arc<ContributionIndex>>) {
|
||||
*self.index.write() = index;
|
||||
}
|
||||
|
||||
/// Bumped on every change: clients compare it to skip re-projection.
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<UiContribution> {
|
||||
self.items.read().iter().map(|i| i.contribution.clone()).collect()
|
||||
}
|
||||
|
||||
/// Items of one point, optionally of one plugin.
|
||||
pub fn items(&self, point: &str, plugin: Option<&str>) -> Vec<UiContribution> {
|
||||
self.items
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|i| i.contribution.point == point && plugin.is_none_or(|p| i.contribution.plugin_id == p))
|
||||
.map(|i| i.contribution.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`UiPoint`]'s validator produces for one declaration.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UiDecl {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub json: serde_json::Value,
|
||||
/// Package files referenced (copied on install).
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Large validated payloads fetched separately by the client.
|
||||
pub assets: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl UiDecl {
|
||||
pub fn new(key: impl Into<String>, label: impl Into<String>, json: serde_json::Value) -> Self {
|
||||
Self { key: key.into(), label: label.into(), json, files: Vec::new(), assets: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
type Validate = dyn Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync;
|
||||
|
||||
/// A `Ui` extension point defined by a validator: committing stores the
|
||||
/// validated JSON (and assets) in the [`UiStore`]. Most UI contribution types
|
||||
/// (menus, keybindings, status bar items, icon themes) are one of these.
|
||||
pub struct UiPoint {
|
||||
id: &'static str,
|
||||
store: Arc<UiStore>,
|
||||
validate: Box<Validate>,
|
||||
}
|
||||
|
||||
impl UiPoint {
|
||||
pub fn new(
|
||||
id: &'static str,
|
||||
store: Arc<UiStore>,
|
||||
validate: impl Fn(&Package, toml::Value) -> Result<UiDecl, String> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self { id, store, validate: Box::new(validate) }
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtensionPoint for UiPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Ui
|
||||
}
|
||||
|
||||
fn prepare(&self, pkg: &Package, decl: toml::Value) -> Result<Prepared, String> {
|
||||
let index = pkg.digest.as_ref().and_then(|_| self.store.index.read().clone());
|
||||
let ui = match index.as_ref().and_then(|i| i.get(pkg, self.id, &decl)) {
|
||||
Some(ui) => ui,
|
||||
None => match &index {
|
||||
Some(i) => {
|
||||
let ui = (self.validate)(pkg, decl.clone())?;
|
||||
i.put(pkg, self.id, &decl, &ui);
|
||||
ui
|
||||
}
|
||||
None => (self.validate)(pkg, decl)?,
|
||||
},
|
||||
};
|
||||
let UiDecl { key, label, json, files, assets } = ui;
|
||||
let store = self.store.clone();
|
||||
let contribution =
|
||||
UiContribution { plugin_id: pkg.id().to_owned(), point: self.id.to_owned(), key: key.clone(), decl: json };
|
||||
let plugin = pkg.id().to_owned();
|
||||
let mut prepared = Prepared::new(label, move || {
|
||||
let item = store.insert(contribution);
|
||||
if assets.is_empty() {
|
||||
return Ok(item);
|
||||
}
|
||||
let assets = store.insert_assets(&plugin, assets);
|
||||
Ok(Disposable::new(move || {
|
||||
item.dispose();
|
||||
assets.dispose();
|
||||
}))
|
||||
})
|
||||
.key(key);
|
||||
prepared.files = files;
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
/// TOML declaration → JSON for the client.
|
||||
pub fn to_json(v: &toml::Value) -> serde_json::Value {
|
||||
serde_json::to_value(v).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::registry::ExtensionRegistry;
|
||||
|
||||
#[test]
|
||||
fn ui_point_projects_into_the_store() {
|
||||
let store = UiStore::new();
|
||||
let reg = ExtensionRegistry::new();
|
||||
reg.register(Arc::new(UiPoint::new("badges", store.clone(), |_pkg, decl| {
|
||||
let text = decl.get("text").and_then(|v| v.as_str()).ok_or("missing text")?.to_owned();
|
||||
let mut d = UiDecl::new(text.clone(), text.clone(), serde_json::json!({ "text": text }));
|
||||
d.assets.push((format!("badge/{text}"), "<svg/>".into()));
|
||||
Ok(d)
|
||||
})))
|
||||
.unwrap();
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.badges]]\ntext = \"hi\"";
|
||||
let pkg = Package::new(Manifest::parse(src).unwrap(), PathBuf::from("/x"));
|
||||
let rev = store.revision();
|
||||
reg.commit(reg.prepare(&pkg).unwrap()).unwrap();
|
||||
assert!(store.revision() > rev);
|
||||
let items = store.items("badges", Some("p"));
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].decl["text"], "hi");
|
||||
assert_eq!(store.asset("p", "badge/hi").as_deref(), Some("<svg/>"));
|
||||
assert_eq!(store.assets_with_prefix("p", "badge/").len(), 1);
|
||||
reg.dispose_plugin("p");
|
||||
assert!(store.snapshot().is_empty());
|
||||
assert!(store.asset("p", "badge/hi").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Syntax check for `when` clauses in UI declarations. The client evaluates
|
||||
//! them (`src/platform/context.ts`); validating here keeps the client's
|
||||
//! projection from failing. Grammar (same as the client):
|
||||
//!
|
||||
//! ```text
|
||||
//! expr := or ; or := and ("||" and)* ; and := unary ("&&" unary)*
|
||||
//! unary := "!" unary | "(" expr ")" | cmp ; cmp := key (("==" | "!=") value)?
|
||||
//! ```
|
||||
|
||||
const MAX_LEN: usize = 512;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Tok<'a> {
|
||||
Id(&'a str),
|
||||
Str,
|
||||
Op(&'a str),
|
||||
}
|
||||
|
||||
fn tokenize(src: &str) -> Result<Vec<Tok<'_>>, String> {
|
||||
let mut out = Vec::new();
|
||||
let b = src.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let c = b[i] as char;
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if ["&&", "||", "==", "!="].iter().any(|op| src[i..].starts_with(op)) {
|
||||
out.push(Tok::Op(&src[i..i + 2]));
|
||||
i += 2;
|
||||
} else if matches!(c, '!' | '(' | ')') {
|
||||
out.push(Tok::Op(&src[i..i + 1]));
|
||||
i += 1;
|
||||
} else if c == '\'' || c == '"' {
|
||||
let end = src[i + 1..].find(c).ok_or_else(|| format!("unterminated string in when clause: {src}"))?;
|
||||
out.push(Tok::Str);
|
||||
i += end + 2;
|
||||
} else {
|
||||
let len = src[i..]
|
||||
.find(|ch: char| !(ch.is_alphanumeric() || matches!(ch, '_' | '.' | '-' | ':' | '/')))
|
||||
.unwrap_or(src.len() - i);
|
||||
if len == 0 {
|
||||
return Err(format!("unexpected '{c}' in when clause: {src}"));
|
||||
}
|
||||
out.push(Tok::Id(&src[i..i + len]));
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Checks a `when` clause; empty means "always".
|
||||
pub fn validate_when(src: &str) -> Result<(), String> {
|
||||
if src.len() > MAX_LEN {
|
||||
return Err(format!("when clause longer than {MAX_LEN} bytes"));
|
||||
}
|
||||
if src.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let toks = tokenize(src)?;
|
||||
let mut p = 0;
|
||||
parse_or(&toks, &mut p, src)?;
|
||||
if p != toks.len() {
|
||||
return Err(format!("unexpected token in when clause: {src}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_or(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
parse_and(t, p, src)?;
|
||||
while t.get(*p) == Some(&Tok::Op("||")) {
|
||||
*p += 1;
|
||||
parse_and(t, p, src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_and(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
parse_unary(t, p, src)?;
|
||||
while t.get(*p) == Some(&Tok::Op("&&")) {
|
||||
*p += 1;
|
||||
parse_unary(t, p, src)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
match t.get(*p) {
|
||||
Some(Tok::Op("!")) => {
|
||||
*p += 1;
|
||||
parse_unary(t, p, src)
|
||||
}
|
||||
Some(Tok::Op("(")) => {
|
||||
*p += 1;
|
||||
parse_or(t, p, src)?;
|
||||
if t.get(*p) != Some(&Tok::Op(")")) {
|
||||
return Err(format!("missing ')' in when clause: {src}"));
|
||||
}
|
||||
*p += 1;
|
||||
Ok(())
|
||||
}
|
||||
Some(Tok::Id(id)) => {
|
||||
if let Some(path) = id.strip_prefix("workspaceContains:")
|
||||
&& (path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path.contains('\\')
|
||||
|| path.split('/').any(|s| s == ".."))
|
||||
{
|
||||
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
|
||||
}
|
||||
*p += 1;
|
||||
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
|
||||
*p += 1;
|
||||
match t.get(*p) {
|
||||
Some(Tok::Id(_) | Tok::Str) => *p += 1,
|
||||
_ => return Err(format!("missing value after comparison in when clause: {src}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("expected a context key in when clause: {src}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_when;
|
||||
|
||||
#[test]
|
||||
fn accepts_the_client_grammar() {
|
||||
for ok in [
|
||||
"",
|
||||
"editorFocus",
|
||||
"editorLang == rust",
|
||||
"editorLang != 'rust' && !readOnly",
|
||||
"(a || b) && toolWindow.project.active",
|
||||
"resourceScheme == file:/x",
|
||||
] {
|
||||
assert!(validate_when(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["a &&", "(a", "a == ", "== a", "a b", "a $ b", "'x", "!()"] {
|
||||
assert!(validate_when(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_contains_paths_stay_in_the_project() {
|
||||
for ok in [
|
||||
"workspaceContains:pom.xml",
|
||||
"workspaceContains:.devcontainer/devcontainer.json",
|
||||
"!workspaceContains:a/b && editorFocus",
|
||||
] {
|
||||
assert!(validate_when(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"workspaceContains:",
|
||||
"workspaceContains:/etc/passwd",
|
||||
"workspaceContains:../x",
|
||||
"workspaceContains:a/../../x",
|
||||
] {
|
||||
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
-21
@@ -21,6 +21,18 @@ pub struct FontFamily {
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
/// PANOSE bProportion value 9 means "Monospaced"; DirectWrite's
|
||||
/// `IsMonospacedFont` alone misses fonts that only carry this classification.
|
||||
const PANOSE_MONOSPACED: u8 = 9;
|
||||
|
||||
/// One face counts as monospaced when the font system flags it fixed pitch, its
|
||||
/// PANOSE proportion is "Monospaced", or its glyph advances are uniform
|
||||
/// (fonts with wrong/missing metadata, e.g. bitmap-style TTFs).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn face_is_mono(is_monospaced_font: bool, panose_proportion: u8, advances_equal: bool) -> bool {
|
||||
is_monospaced_font || panose_proportion == PANOSE_MONOSPACED || advances_equal
|
||||
}
|
||||
|
||||
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use and
|
||||
@@ -195,7 +207,9 @@ mod imp {
|
||||
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
descriptors
|
||||
.cast_unchecked::<CTFontDescriptor>()
|
||||
@@ -271,14 +285,16 @@ mod imp {
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Graphics::DirectWrite::IDWriteFontFace1;
|
||||
use windows::Win32::Graphics::DirectWrite::{
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory, IDWriteFactory3, IDWriteFactory5, IDWriteFont,
|
||||
IDWriteFont1, IDWriteFontCollection, IDWriteFontFamily, IDWriteLocalizedStrings,
|
||||
};
|
||||
use windows::core::{BOOL, Interface, w};
|
||||
use windows::core::{BOOL, Interface, PCWSTR, w};
|
||||
|
||||
/// The system font collection's families, named in English when the
|
||||
/// family has an English name (what CSS matches), else its first name.
|
||||
/// The installed families, named in English when the family has an
|
||||
/// English name (what CSS matches), else its first name.
|
||||
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read(refresh) }.unwrap_or_default()
|
||||
@@ -286,11 +302,85 @@ mod imp {
|
||||
|
||||
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
|
||||
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
|
||||
let mut faces = Vec::new();
|
||||
// Windows 10+: the newer collection covers registered per-user fonts.
|
||||
// Exclude downloadable fonts that are not installed on this machine.
|
||||
// Bitmap .FON fonts (e.g. the stock raster Fixedsys) are invisible to
|
||||
// DirectWrite either way: it only reads TrueType/OpenType files.
|
||||
let mut collection = None;
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
if let Ok(factory3) = factory.cast::<IDWriteFactory3>() {
|
||||
let mut local = None;
|
||||
if unsafe { factory3.GetSystemFontCollection(false, &mut local, refresh) }.is_ok() {
|
||||
collection = local.map(Into::into);
|
||||
}
|
||||
}
|
||||
if collection.is_none() {
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
}
|
||||
if let Some(collection) = collection {
|
||||
faces.extend(collect(&collection));
|
||||
}
|
||||
// DirectWrite enumerates via the font registry, so font files copied
|
||||
// into the per-user directory without registering (no HKCU entry)
|
||||
// are invisible to the calls above; scan the directory directly.
|
||||
faces.extend(unsafe { user_dir_faces(&factory) });
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// Families from `%LOCALAPPDATA%\Microsoft\Windows\Fonts` files, registered
|
||||
/// or not, via a font set built from the files on disk.
|
||||
unsafe fn user_dir_faces(factory: &IDWriteFactory) -> Vec<(String, bool)> {
|
||||
let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let dir = std::path::Path::new(&local_app_data).join(r"Microsoft\Windows\Fonts");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
matches!(
|
||||
p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
|
||||
Some("ttf" | "otf" | "ttc" | "otc")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
unsafe { file_faces(factory, &files) }
|
||||
}
|
||||
|
||||
unsafe fn file_faces(factory: &IDWriteFactory, files: &[std::path::PathBuf]) -> Vec<(String, bool)> {
|
||||
if files.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(factory3) = factory.cast::<IDWriteFactory3>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(factory5) = factory.cast::<IDWriteFactory5>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(builder) = (unsafe { factory5.CreateFontSetBuilder() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
for path in files {
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
// SAFETY: wide is a valid NUL-terminated string for the calls.
|
||||
let Ok(file) = (unsafe { factory.CreateFontFileReference(PCWSTR(wide.as_ptr()), None) }) else {
|
||||
continue;
|
||||
};
|
||||
let _ = unsafe { builder.AddFontFile(&file) };
|
||||
}
|
||||
let Ok(set) = (unsafe { builder.CreateFontSet() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(collection) = (unsafe { factory3.CreateFontCollectionFromFontSet(&set) }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
collect(&collection)
|
||||
}
|
||||
|
||||
fn collect(collection: &IDWriteFontCollection) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..unsafe { collection.GetFontFamilyCount() } {
|
||||
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
|
||||
@@ -299,19 +389,120 @@ mod imp {
|
||||
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
|
||||
continue;
|
||||
};
|
||||
let mono = unsafe {
|
||||
family.GetFirstMatchingFont(
|
||||
DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWRITE_FONT_STRETCH_NORMAL,
|
||||
DWRITE_FONT_STYLE_NORMAL,
|
||||
)
|
||||
}
|
||||
.ok()
|
||||
.and_then(|f| f.cast::<IDWriteFont1>().ok())
|
||||
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
|
||||
out.push((name, mono));
|
||||
out.push((name, unsafe { family_is_mono(&family) }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A family is monospaced when any face reports fixed pitch, PANOSE
|
||||
/// proportion 9, or uniform glyph advances; probing every face matches
|
||||
/// `normalize`'s any-face merge (macOS and Linux already flag every face,
|
||||
/// not just the regular one).
|
||||
unsafe fn family_is_mono(family: &IDWriteFontFamily) -> bool {
|
||||
(0..unsafe { family.GetFontCount() }).any(|i| {
|
||||
let Ok(font) = (unsafe { family.GetFont(i) }) else {
|
||||
return false;
|
||||
};
|
||||
if let Ok(font1) = font.cast::<IDWriteFont1>() {
|
||||
let is_monospaced = unsafe { font1.IsMonospacedFont() }.as_bool();
|
||||
// The proportion byte is defined for text/display fonts only.
|
||||
let panose = unsafe { font1.GetPanose().values };
|
||||
let panose_proportion = if panose[0] == 2 { panose[3] } else { 0 };
|
||||
if super::face_is_mono(is_monospaced, panose_proportion, false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
unsafe { advances_equal(&font) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Some monospaced fonts carry no fixed-pitch metadata at all (bitmap-style
|
||||
/// TTFs like Fixedsys Excelsior): compare the advance widths of 'i' and
|
||||
/// 'W'. Dual-width CJK monospace fonts pass too (both half-width).
|
||||
unsafe fn advances_equal(font: &IDWriteFont) -> bool {
|
||||
let Ok(face) = (unsafe { font.CreateFontFace() }) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(face1) = face.cast::<IDWriteFontFace1>() else {
|
||||
return false;
|
||||
};
|
||||
let codepoints = ['i' as u32, 'W' as u32];
|
||||
let mut indices = [0u16; 2];
|
||||
if unsafe { face.GetGlyphIndices(codepoints.as_ptr(), 2, indices.as_mut_ptr()) }.is_err()
|
||||
|| indices.contains(&0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut advances = [0i32; 2];
|
||||
unsafe { face1.GetDesignGlyphAdvances(2, indices.as_ptr(), advances.as_mut_ptr(), false) }.is_ok()
|
||||
&& advances[0] > 0
|
||||
&& advances[0] == advances[1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_probe_distinguishes_monospace_proportional_and_symbol_fonts() {
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let mut collection = None;
|
||||
factory.GetSystemFontCollection(&mut collection, false).unwrap();
|
||||
let collection = collection.unwrap();
|
||||
for (name, expected) in [
|
||||
("Consolas", true),
|
||||
("Arial", false),
|
||||
("Times New Roman", false),
|
||||
("Wingdings", false),
|
||||
("Webdings", false),
|
||||
("Segoe MDL2 Assets", false),
|
||||
] {
|
||||
let wide: Vec<_> = name.encode_utf16().chain(Some(0)).collect();
|
||||
let mut index = 0;
|
||||
let mut exists = BOOL(0);
|
||||
collection.FindFamilyName(PCWSTR(wide.as_ptr()), &mut index, &mut exists).unwrap();
|
||||
assert!(exists.as_bool(), "{name} ships with Windows");
|
||||
let family = collection.GetFontFamily(index).unwrap();
|
||||
for i in 0..family.GetFontCount() {
|
||||
let font = family.GetFont(i).unwrap();
|
||||
assert_eq!(advances_equal(&font), expected, "{name} face {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_files_include_every_family_in_a_collection() {
|
||||
let path = std::path::PathBuf::from(std::env::var_os("WINDIR").unwrap()).join(r"Fonts\cambria.ttc");
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let faces = file_faces(&factory, &[path]);
|
||||
for name in ["Cambria", "Cambria Math"] {
|
||||
assert!(faces.iter().any(|(family, _)| family == name), "missing {name}: {faces:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_families_available_only_as_downloads() {
|
||||
use windows::Win32::Graphics::DirectWrite::{DWRITE_E_REMOTEFONT, DWRITE_LOCALITY_LOCAL, IDWriteFont3};
|
||||
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let factory3: IDWriteFactory3 = factory.cast().unwrap();
|
||||
let set = factory3.GetSystemFontSet().unwrap();
|
||||
let collection = factory3.CreateFontCollectionFromFontSet(&set).unwrap();
|
||||
let listed = super::normalize(read(false).unwrap());
|
||||
let user = super::normalize(user_dir_faces(&factory));
|
||||
for i in 0..collection.GetFontFamilyCount() {
|
||||
let family = collection.GetFontFamily(i).unwrap();
|
||||
let name = english(&family.GetFamilyNames().unwrap()).unwrap();
|
||||
let remote_only = (0..family.GetFontCount()).all(|j| match family.GetFont(j) {
|
||||
Ok(font) => font.cast::<IDWriteFont3>().unwrap().GetLocality() != DWRITE_LOCALITY_LOCAL,
|
||||
Err(error) => error.code() == DWRITE_E_REMOTEFONT,
|
||||
});
|
||||
if remote_only && !user.iter().any(|f| f.name == name) {
|
||||
assert!(!listed.iter().any(|f| f.name == name), "{name} is not installed locally");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
|
||||
@@ -476,6 +667,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panose_monospaced_proportion_counts_as_mono() {
|
||||
assert!(face_is_mono(true, 0, false));
|
||||
assert!(face_is_mono(false, PANOSE_MONOSPACED, false));
|
||||
assert!(face_is_mono(false, 0, true));
|
||||
assert!(!face_is_mono(false, 0, false));
|
||||
assert!(!face_is_mono(false, 3, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fc_list_spacing_marks_monospace() {
|
||||
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The signed-in GitHub account, independent of any project: its repositories for
|
||||
//! "Get from Version Control" › GitHub.
|
||||
|
||||
use crate::{GhAvailability, QUICK, classify_failure, command, gh_executable, process};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// A repository of the signed-in account (owned, collaborator, or organization member).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhAccountRepo {
|
||||
/// `owner/name`.
|
||||
pub name_with_owner: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub fork: bool,
|
||||
pub archived: bool,
|
||||
/// What `git clone` takes: HTTPS, or SSH when gh's `git_protocol` is `ssh`.
|
||||
pub clone_url: String,
|
||||
}
|
||||
|
||||
/// The account's repositories, or why they can't be listed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum GhAccountRepos {
|
||||
Ready {
|
||||
login: String,
|
||||
repos: Vec<GhAccountRepo>,
|
||||
},
|
||||
/// No `gh` executable was found.
|
||||
NotInstalled,
|
||||
/// `gh auth login` has not been run (or the token expired).
|
||||
NotLoggedIn {
|
||||
message: String,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RestRepo {
|
||||
full_name: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
private: bool,
|
||||
#[serde(default)]
|
||||
fork: bool,
|
||||
#[serde(default)]
|
||||
archived: bool,
|
||||
clone_url: String,
|
||||
ssh_url: String,
|
||||
}
|
||||
|
||||
/// `gh api --paginate --slurp user/repos` output: one array per page.
|
||||
pub fn parse_account_repos(slurped: &str, ssh: bool) -> Result<Vec<GhAccountRepo>, serde_json::Error> {
|
||||
let pages: Vec<Vec<RestRepo>> = serde_json::from_str(slurped)?;
|
||||
Ok(pages
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|r| GhAccountRepo {
|
||||
name_with_owner: r.full_name,
|
||||
description: r.description.filter(|d| !d.trim().is_empty()),
|
||||
private: r.private,
|
||||
fork: r.fork,
|
||||
archived: r.archived,
|
||||
clone_url: if ssh { r.ssh_url } else { r.clone_url },
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Lists the repositories of the account gh is signed in to, most recently pushed first.
|
||||
pub fn account_repositories() -> GhAccountRepos {
|
||||
if gh_executable().is_none() {
|
||||
return GhAccountRepos::NotInstalled;
|
||||
}
|
||||
let dir = std::env::temp_dir();
|
||||
let gh = |args: &[&str]| -> Result<String, GhAccountRepos> {
|
||||
let out = command(&dir, args)
|
||||
.and_then(|cmd| process::run(cmd, QUICK, None))
|
||||
.map_err(|e| GhAccountRepos::Error { message: e.message })?;
|
||||
if out.success {
|
||||
return Ok(out.stdout);
|
||||
}
|
||||
let message = if out.stderr.trim().is_empty() { out.stdout } else { out.stderr };
|
||||
Err(match classify_failure(&message) {
|
||||
GhAvailability::NotLoggedIn { message } => GhAccountRepos::NotLoggedIn { message },
|
||||
_ => GhAccountRepos::Error { message: message.trim().to_owned() },
|
||||
})
|
||||
};
|
||||
let result = (|| {
|
||||
let login = gh(&["api", "user", "--jq", ".login"])?.trim().to_owned();
|
||||
let ssh = gh(&["config", "get", "git_protocol", "--host", "github.com"]).is_ok_and(|p| p.trim() == "ssh");
|
||||
let pages = gh(&[
|
||||
"api",
|
||||
"user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
|
||||
"--paginate",
|
||||
"--slurp",
|
||||
])?;
|
||||
let repos = parse_account_repos(&pages, ssh)
|
||||
.map_err(|e| GhAccountRepos::Error { message: format!("unexpected gh output: {e}") })?;
|
||||
Ok(GhAccountRepos::Ready { login, repos })
|
||||
})();
|
||||
result.unwrap_or_else(|e| e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_every_page_and_picks_the_configured_protocol() {
|
||||
let pages = r#"[
|
||||
[{"full_name":"me/app","description":"An app","private":true,"fork":false,"archived":false,
|
||||
"clone_url":"https://github.com/me/app.git","ssh_url":"[email protected]:me/app.git"}],
|
||||
[{"full_name":"org/lib","description":"","fork":true,
|
||||
"clone_url":"https://github.com/org/lib.git","ssh_url":"[email protected]:org/lib.git"}]
|
||||
]"#;
|
||||
let https = parse_account_repos(pages, false).unwrap();
|
||||
assert_eq!(
|
||||
https,
|
||||
vec![
|
||||
GhAccountRepo {
|
||||
name_with_owner: "me/app".into(),
|
||||
description: Some("An app".into()),
|
||||
private: true,
|
||||
fork: false,
|
||||
archived: false,
|
||||
clone_url: "https://github.com/me/app.git".into(),
|
||||
},
|
||||
GhAccountRepo {
|
||||
name_with_owner: "org/lib".into(),
|
||||
description: None,
|
||||
private: false,
|
||||
fork: true,
|
||||
archived: false,
|
||||
clone_url: "https://github.com/org/lib.git".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
let ssh = parse_account_repos(pages, true).unwrap();
|
||||
assert_eq!(ssh[1].clone_url, "[email protected]:org/lib.git");
|
||||
assert!(parse_account_repos("[]", false).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod account;
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use account::{GhAccountRepo, GhAccountRepos, account_repositories, parse_account_repos};
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
|
||||
@@ -14,6 +14,7 @@ encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-index.workspace = true
|
||||
@@ -30,15 +31,25 @@ ide-terminal.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-sdk.workspace = true
|
||||
ide-http.workspace = true
|
||||
reqwest.workspace = true
|
||||
ignore.workspace = true
|
||||
base64.workspace = true
|
||||
getrandom = "0.3"
|
||||
globset.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
ide-extension.workspace = true
|
||||
ide-plugin.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
@@ -49,13 +60,20 @@ dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
zip.workspace = true
|
||||
quick-xml = "0.42"
|
||||
|
||||
[dev-dependencies]
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1.1", features = ["fs", "process"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
[[bench]]
|
||||
name = "plugins"
|
||||
harness = false
|
||||
@@ -0,0 +1,149 @@
|
||||
//! `cargo bench -p ide-host`: the plugin runtime budgets, checked by `pnpm perf:rust` against
|
||||
//! `perf/budgets.json` › rust (`plugins/…`).
|
||||
//!
|
||||
//! - `load_50`: start-up load of 50 installed plugins (20 with code, 10
|
||||
//! languages with a syntax provider, 10 icon themes, 10 declarations only),
|
||||
//! from the contribution index; no Wasm is instantiated.
|
||||
//! - `cold_activation`: an idle-reclaimed plugin's next command: activation
|
||||
//! from the precompiled component plus the call.
|
||||
//! - `syntax_highlights_2k_lines`: an edit-path call (Solidity highlighting).
|
||||
|
||||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||
use ide_core::LanguageId;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let name = e.file_name();
|
||||
if matches!(name.to_str(), Some("target" | "src" | "Cargo.lock" | ".gitignore")) {
|
||||
continue;
|
||||
}
|
||||
let dst = to.join(&name);
|
||||
if e.path().is_dir() { copy_dir(&e.path(), &dst) } else { std::fs::copy(e.path(), &dst).map(|_| ()).unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `src` installed under a new id (and new language / theme ids).
|
||||
fn variant(work: &Path, src: &Path, n: usize, rename: &[(&str, String)]) -> PathBuf {
|
||||
let dir = work.join(format!("pkg{n}"));
|
||||
copy_dir(src, &dir);
|
||||
for file in ["plugin.toml", "languages/solidity.toml"] {
|
||||
let path = dir.join(file);
|
||||
if let Ok(mut text) = std::fs::read_to_string(&path) {
|
||||
for (from, to) in rename {
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
std::fs::write(&path, text).unwrap();
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn sink() -> Arc<dyn ide_host::plugins::PluginEventSink> {
|
||||
Arc::new(|_: Option<&str>, _| {})
|
||||
}
|
||||
|
||||
fn install_50(plugins: &Path, work: &Path) {
|
||||
let host = PluginHost::new(plugins.to_path_buf(), sink(), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
let root = repo();
|
||||
let mut n = 0;
|
||||
let mut install = |src: PathBuf, rename: Vec<(&str, String)>| {
|
||||
let pkg = variant(work, &src, n, &rename);
|
||||
n += 1;
|
||||
host.install_from_dir(&pkg, &Consent::default()).unwrap();
|
||||
};
|
||||
for i in 0..20 {
|
||||
install(
|
||||
root.join("crates/ide-plugin/tests/fixtures/hello"),
|
||||
vec![("id = \"hello\"", format!("id = \"bench.hello{i}\""))],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
install(
|
||||
root.join("plugins/official/solidity"),
|
||||
vec![
|
||||
("openidea.solidity", format!("bench.solidity{i}")),
|
||||
("id = \"solidity\"", format!("id = \"solidity{i}\"")),
|
||||
("extensions = [\"sol\"]", format!("extensions = [\"sol{i}\"]")),
|
||||
("on-language:solidity", format!("on-language:solidity{i}")),
|
||||
("id = \"forge-", format!("id = \"f{i}-")),
|
||||
("id = \"hardhat-", format!("id = \"h{i}-")),
|
||||
],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
install(
|
||||
root.join("plugins/official/material-icons"),
|
||||
vec![
|
||||
("openidea.material-icons", format!("bench.icons{i}")),
|
||||
("id = \"material\"", format!("id = \"material{i}\"")),
|
||||
],
|
||||
);
|
||||
}
|
||||
for i in 0..10 {
|
||||
let dir = work.join(format!("static{i}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("plugin.toml"),
|
||||
format!(
|
||||
"id = \"bench.static{i}\"\nname = \"Static {i}\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
|
||||
[[contributes.commands]]\nid = \"a\"\ntitle = \"Static {i}: A\"\nwhen = \"editorLang == rust\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
install_50(&plugins, &tmp.path().join("work"));
|
||||
|
||||
let mut g = c.benchmark_group("plugins");
|
||||
g.sample_size(20);
|
||||
g.bench_function("load_50", |b| {
|
||||
b.iter(|| {
|
||||
let host = PluginHost::new(plugins.clone(), sink(), Limits::default()).unwrap();
|
||||
host.load_all();
|
||||
assert_eq!(host.instances(), 0, "start-up instantiates no Wasm");
|
||||
black_box(host.list(None).plugins.len())
|
||||
})
|
||||
});
|
||||
|
||||
let limits = Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() };
|
||||
let host = Arc::new(PluginHost::new(plugins.clone(), sink(), limits).unwrap());
|
||||
host.load_all();
|
||||
host.warm_component_cache();
|
||||
g.bench_function("cold_activation", |b| {
|
||||
b.iter(|| {
|
||||
host.tick();
|
||||
// Fails for want of an editor, after activating and running.
|
||||
let r = host.run_command("bench.hello0", "word-count", None, Invocation::default());
|
||||
black_box(r.is_err())
|
||||
})
|
||||
});
|
||||
|
||||
let provider = host.syntax_provider();
|
||||
let lang = LanguageId::new("solidity0");
|
||||
let unit = "contract Token {\n uint256 public total;\n mapping(address => uint256) balances;\n event Sent(address to, uint256 v);\n function send(address to, uint256 v) public returns (bool) {\n // move funds\n balances[to] += v;\n emit Sent(to, v);\n return true;\n }\n}\n";
|
||||
let text = unit.repeat(2000 / unit.lines().count());
|
||||
assert!(provider.highlights(&lang, &text).is_some());
|
||||
g.bench_function("syntax_highlights_2k_lines", |b| {
|
||||
b.iter(|| black_box(provider.highlights(&lang, &text).map(|t| t.len())))
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,988 @@
|
||||
//! `openidea-plugin`: scaffolds, builds, checks and links OpenIDEA plugins.
|
||||
//!
|
||||
//! ```text
|
||||
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
|
||||
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
|
||||
//! openidea-plugin check [dir|package] # validate the package as the IDE would install it
|
||||
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
|
||||
//! openidea-plugin unlink [dir]
|
||||
//! openidea-plugin links # list linked folders
|
||||
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
|
||||
//! openidea-plugin endorse <new-public-key> --key OLD --publisher ID # vouch for a new key
|
||||
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
|
||||
//! openidea-plugin index <dir> [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json
|
||||
//! openidea-plugin merge <index> <other-index> --keep ID # list another index's publishers
|
||||
//! ```
|
||||
//!
|
||||
//! `link` writes `links.json` in the IDE's plugins directory
|
||||
//! (`$OPENIDEA_PLUGINS_DIR`, or the app data directory); the IDE picks the
|
||||
//! change up when Settings › Plugins is opened or refreshed. Linked plugins
|
||||
//! are re-read from the folder, so `build` + refresh is the edit loop.
|
||||
|
||||
mod templates;
|
||||
|
||||
use ide_extension::{Manifest, validate_plugin_id};
|
||||
use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href};
|
||||
use ide_plugin::{Limits, PluginEngine};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
const USAGE: &str = "\
|
||||
usage: openidea-plugin <command> [options]
|
||||
|
||||
commands:
|
||||
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
|
||||
create a plugin; T = command (default), theme, icon-theme, language. A Rust
|
||||
plugin depends on the SDK of this tool's release (git); --sdk PATH uses a
|
||||
local sdk/rust/openidea-plugin instead
|
||||
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
|
||||
check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it
|
||||
link [dir] load the folder in the IDE without installing it
|
||||
unlink [dir] stop loading a linked folder
|
||||
links list linked folders
|
||||
keygen <key-file> create a publisher signing key (keep the file secret)
|
||||
endorse <new-public-key> --key F --publisher ID
|
||||
print an earlier key's endorsement of a new one (JSON), for an
|
||||
index the publisher doesn't write with `index --previous-key`
|
||||
pack [dir] [--key F --publisher ID] [--out FILE]
|
||||
write <id>-<version>.oiplugin, signed with the key
|
||||
index <dir> [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE]
|
||||
[--download-base URL]
|
||||
write the marketplace index.json for the packages in dir: with a
|
||||
key, register it and list that publisher's plugins (earlier
|
||||
versions stay for older IDEs); without, list only these packages,
|
||||
each checked against the index's publishers. A new key replacing
|
||||
an earlier one is endorsed with --previous-key, so installed
|
||||
plugins update to it without asking
|
||||
merge <index> <other-index> --keep ID [--out FILE]
|
||||
replace every publisher but ID in index with those of other-index
|
||||
(a path or URL), their plugins and revocations
|
||||
|
||||
environment:
|
||||
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => cmd_new(&args[1..]),
|
||||
Some("build") => cmd_build(&args[1..]),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check_path(&d)),
|
||||
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
|
||||
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
|
||||
Some("links") => list_links(),
|
||||
Some("keygen") => cmd_keygen(&args[1..]),
|
||||
Some("endorse") => cmd_endorse(&args[1..]),
|
||||
Some("pack") => cmd_pack(&args[1..]),
|
||||
Some("index") => cmd_index(&args[1..]),
|
||||
Some("merge") => cmd_merge(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
|
||||
Ok(())
|
||||
}
|
||||
Some("help" | "--help" | "-h") | None => {
|
||||
println!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown command {other:?}\n\n{USAGE}")),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `args` into positionals and `--flag value` / `--switch` options.
|
||||
struct Args {
|
||||
positional: Vec<String>,
|
||||
options: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String], switches: &[&str], valued: &[&str]) -> Result<Self, String> {
|
||||
let mut out = Args { positional: Vec::new(), options: Vec::new() };
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if let Some(name) = a.strip_prefix("--") {
|
||||
let (name, inline) = match name.split_once('=') {
|
||||
Some((n, v)) => (n, Some(v.to_owned())),
|
||||
None => (name, None),
|
||||
};
|
||||
if switches.contains(&name) && inline.is_none() {
|
||||
out.options.push((name.into(), None));
|
||||
} else if valued.contains(&name) {
|
||||
let v = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("--{name} needs a value"))?;
|
||||
out.options.push((name.into(), Some(v)));
|
||||
} else {
|
||||
return Err(format!("unknown option --{name}"));
|
||||
}
|
||||
} else {
|
||||
out.positional.push(a.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn value(&self, name: &str) -> Option<&str> {
|
||||
self.options.iter().rev().find(|(n, _)| n == name).and_then(|(_, v)| v.as_deref())
|
||||
}
|
||||
|
||||
fn has(&self, name: &str) -> bool {
|
||||
self.options.iter().any(|(n, _)| n == name)
|
||||
}
|
||||
|
||||
fn dir(&self) -> Result<PathBuf, String> {
|
||||
match self.positional.as_slice() {
|
||||
[] => Ok(PathBuf::from(".")),
|
||||
[d] => Ok(PathBuf::from(d)),
|
||||
_ => Err("expected at most one directory".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_arg(args: &[String]) -> Result<PathBuf, String> {
|
||||
Args::parse(args, &[], &[])?.dir()
|
||||
}
|
||||
|
||||
fn canonical(dir: &Path) -> Result<PathBuf, String> {
|
||||
dunce::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- new
|
||||
|
||||
fn cmd_new(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["template", "id", "name", "sdk"])?;
|
||||
let [dir] = a.positional.as_slice() else {
|
||||
return Err("usage: openidea-plugin new <dir> [--template T] [--id ID] [--name NAME]".into());
|
||||
};
|
||||
let dir = PathBuf::from(dir);
|
||||
let template = templates::Template::parse(a.value("template").unwrap_or("command"))?;
|
||||
let base = dir.file_name().and_then(|n| n.to_str()).ok_or("the directory needs a name")?;
|
||||
let id = match a.value("id") {
|
||||
Some(id) => id.to_owned(),
|
||||
None => slugify(base),
|
||||
};
|
||||
validate_plugin_id(&id)?;
|
||||
let name = match a.value("name") {
|
||||
Some(n) => n.to_owned(),
|
||||
None => title_case(id.rsplit('.').next().unwrap_or(&id)),
|
||||
};
|
||||
if name.trim().is_empty() || name.contains(['"', '\\', '\n']) {
|
||||
return Err("--name must not be empty or contain quotes, backslashes or newlines".into());
|
||||
}
|
||||
if dir.exists() && std::fs::read_dir(&dir).map_err(|e| e.to_string())?.next().is_some() {
|
||||
return Err(format!("{} already exists and is not empty", dir.display()));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let dir = canonical(&dir)?;
|
||||
let sdk = match template {
|
||||
templates::Template::Command => Some(sdk_dependency(a.value("sdk"), &dir)?),
|
||||
_ => None,
|
||||
};
|
||||
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
|
||||
let files = templates::render(template, &vars);
|
||||
for (rel, content) in &files {
|
||||
let path = dir.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, content).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
// Generated packages must be valid as written.
|
||||
Manifest::load(&dir).map_err(|e| format!("internal error: the template is invalid: {e}"))?;
|
||||
println!("created {} ({id}) in {}", template.label(), dir.display());
|
||||
let shown = std::env::current_dir().ok().and_then(|cwd| dir.strip_prefix(cwd).ok().map(Path::to_path_buf));
|
||||
let shown = shown.as_deref().unwrap_or(&dir).display().to_string();
|
||||
println!();
|
||||
println!("next:");
|
||||
if template == templates::Template::Command {
|
||||
println!(" cargo test --manifest-path {shown}/Cargo.toml # unit tests against the mock host");
|
||||
println!(" openidea-plugin build {shown}");
|
||||
} else {
|
||||
println!(" openidea-plugin check {shown}");
|
||||
}
|
||||
println!(" openidea-plugin link {shown} # then Settings › Plugins");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `My Cool_Plugin` → `my-cool-plugin`.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'a'..='z' | '0'..='9' | '.' => out.push(c),
|
||||
'A'..='Z' => out.push(c.to_ascii_lowercase()),
|
||||
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.trim_matches(['-', '.']).to_owned()
|
||||
}
|
||||
|
||||
/// `my-cool_plugin` → `My Cool Plugin`.
|
||||
fn title_case(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// The SDK dependency written into a new plugin's Cargo.toml: by path with `--sdk`, or
|
||||
/// when the plugin is inside the source tree this tool was built from; otherwise the
|
||||
/// repository at this tool's release tag.
|
||||
fn sdk_dependency(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
|
||||
let sdk = match explicit {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => match dunce::canonicalize(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) {
|
||||
Ok(tree) if plugin_dir.starts_with(&tree) => tree.join("sdk/rust/openidea-plugin"),
|
||||
_ => return Ok(format!("{{ git = \"{REPOSITORY}\", tag = \"v{}\" }}", env!("CARGO_PKG_VERSION"))),
|
||||
},
|
||||
};
|
||||
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
|
||||
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
|
||||
})?;
|
||||
if !sdk.join("Cargo.toml").is_file() {
|
||||
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
|
||||
}
|
||||
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
|
||||
Ok(format!("{{ path = \"{}\" }}", path.to_string_lossy().replace('\\', "/")))
|
||||
}
|
||||
|
||||
const REPOSITORY: &str = "https://github.com/l0ng-ai/openidea";
|
||||
|
||||
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
|
||||
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
|
||||
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
|
||||
let common = f.iter().zip(&t).take_while(|(a, b)| a == b).count();
|
||||
if common <= 1 {
|
||||
return None;
|
||||
}
|
||||
let mut out = PathBuf::new();
|
||||
for _ in common..f.len() {
|
||||
out.push("..");
|
||||
}
|
||||
for c in &t[common..] {
|
||||
out.push(c);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build / check
|
||||
|
||||
fn cmd_build(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &["debug"], &[])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
let cargo_toml = dir.join("Cargo.toml");
|
||||
if !cargo_toml.is_file() {
|
||||
if manifest.wasm.is_some() {
|
||||
return Err(format!(
|
||||
"{} has no Cargo.toml: build the component with your toolchain, then run `check`",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
println!("{} has no code to build", manifest.id);
|
||||
return check(&dir);
|
||||
}
|
||||
let wasm_rel = manifest.wasm.clone().ok_or("plugin.toml has no `wasm = \"dist/plugin.wasm\"`")?;
|
||||
let crate_name = crate_name(&cargo_toml)?;
|
||||
ensure_wasm_target();
|
||||
let release = !a.has("debug");
|
||||
let target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| dir.join("target"));
|
||||
let mut cmd = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()));
|
||||
cmd.arg("build").arg("--manifest-path").arg(&cargo_toml).args(["--target", "wasm32-wasip2"]);
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
cmd.env("CARGO_TARGET_DIR", &target_dir);
|
||||
println!("building {crate_name} for wasm32-wasip2 ({})", if release { "release" } else { "debug" });
|
||||
let status = cmd.status().map_err(|e| format!("cannot run cargo: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("cargo build failed".into());
|
||||
}
|
||||
let built = target_dir
|
||||
.join("wasm32-wasip2")
|
||||
.join(if release { "release" } else { "debug" })
|
||||
.join(format!("{}.wasm", crate_name.replace('-', "_")));
|
||||
if !built.is_file() {
|
||||
return Err(format!("{} was not produced: is `crate-type = [\"cdylib\"]` set in Cargo.toml?", built.display()));
|
||||
}
|
||||
let out = dir.join(&wasm_rel);
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::copy(&built, &out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
println!(" -> {} ({})", wasm_rel.display(), human_size(size));
|
||||
check(&dir)
|
||||
}
|
||||
|
||||
fn crate_name(cargo_toml: &Path) -> Result<String, String> {
|
||||
let src = std::fs::read_to_string(cargo_toml).map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
|
||||
let doc: toml::Table = src.parse().map_err(|e| format!("{}: {e}", cargo_toml.display()))?;
|
||||
let package = doc.get("package").and_then(|p| p.as_table());
|
||||
let lib_name = doc.get("lib").and_then(|l| l.get("name")).and_then(|n| n.as_str());
|
||||
lib_name
|
||||
.or_else(|| package.and_then(|p| p.get("name")).and_then(|n| n.as_str()))
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| format!("{}: no [package] name", cargo_toml.display()))
|
||||
}
|
||||
|
||||
/// Installs the target through rustup when it is missing (no-op without rustup).
|
||||
fn ensure_wasm_target() {
|
||||
let Ok(out) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { return };
|
||||
if String::from_utf8_lossy(&out.stdout).lines().any(|l| l.trim() == "wasm32-wasip2") {
|
||||
return;
|
||||
}
|
||||
println!("installing the wasm32-wasip2 target");
|
||||
let _ = Command::new("rustup").args(["target", "add", "wasm32-wasip2"]).status();
|
||||
}
|
||||
|
||||
/// Validates `dir` exactly as Install from Disk would, and checks the
|
||||
/// component imports only the plugin API.
|
||||
/// [`check`] of a folder, or of a package file unpacked as installing would.
|
||||
fn check_path(path: &Path) -> Result<(), String> {
|
||||
if !path.is_file() {
|
||||
return check(path);
|
||||
}
|
||||
let tmp = scratch_dir("unpack")?;
|
||||
let dir = tmp.path().join("package");
|
||||
package::unpack(path, &dir).and_then(|_| check(&dir))
|
||||
}
|
||||
|
||||
fn check(dir: &Path) -> Result<(), String> {
|
||||
let dir = canonical(dir)?;
|
||||
let tmp = scratch_dir("check")?;
|
||||
let scratch = tmp.path().join("host");
|
||||
(|| {
|
||||
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let preview = host.preview_install(&dir).map_err(|e| e.to_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`); see plugins/README.md to move to 0.2");
|
||||
}
|
||||
if let Some(wasm) = &manifest.wasm {
|
||||
let engine = PluginEngine::new()?;
|
||||
let component = engine.compile(&dir.join(wasm))?;
|
||||
let built_for = engine.check_component(&component)?;
|
||||
let size = std::fs::metadata(dir.join(wasm)).map(|m| m.len()).unwrap_or(0);
|
||||
let api = built_for.map(|v| format!(", plugin API {}.{}", v.major, v.minor)).unwrap_or_default();
|
||||
println!(" code: {} ({}{api})", wasm.display(), human_size(size));
|
||||
} else {
|
||||
println!(" code: none (contributions only)");
|
||||
}
|
||||
println!(" categories: {}", manifest.categories().join(", "));
|
||||
for c in manifest.unknown_categories() {
|
||||
println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", "));
|
||||
}
|
||||
if manifest.declared_categories.len() > ide_extension::MAX_CATEGORIES {
|
||||
println!(
|
||||
" warning: only the first {} known categories are used; {} are declared",
|
||||
ide_extension::MAX_CATEGORIES,
|
||||
manifest.declared_categories.len()
|
||||
);
|
||||
}
|
||||
if !manifest.activation.is_empty() {
|
||||
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
|
||||
println!(" activation: {}", events.join(", "));
|
||||
}
|
||||
for c in &preview.contributions {
|
||||
println!(" contributes {} × {}", c.count, c.point);
|
||||
}
|
||||
for c in &preview.capabilities {
|
||||
let kind = if c.optional { "optional" } else { "required" };
|
||||
println!(" capability {} ({kind}, {:?} risk): {}", c.id, c.risk, c.description);
|
||||
}
|
||||
for w in &preview.warnings {
|
||||
println!(" warning: {w}");
|
||||
}
|
||||
println!("ok");
|
||||
Ok(())
|
||||
})()
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b >= 1 << 20 => format!("{:.1} MiB", b as f64 / (1 << 20) as f64),
|
||||
b if b >= 1 << 10 => format!("{:.1} KiB", b as f64 / 1024.0),
|
||||
b => format!("{b} B"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- link
|
||||
|
||||
fn plugins_dir() -> Result<PathBuf, String> {
|
||||
if let Some(dir) = std::env::var_os("OPENIDEA_PLUGINS_DIR") {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
dirs::data_dir()
|
||||
.map(|d| d.join("ai.l0ng.openidea").join("plugins"))
|
||||
.ok_or_else(|| "cannot find the app data directory; set OPENIDEA_PLUGINS_DIR".into())
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
struct Links {
|
||||
#[serde(default)]
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn read_links(file: &Path) -> Result<Links, String> {
|
||||
match std::fs::read_to_string(file) {
|
||||
Ok(s) => serde_json::from_str(&s).map_err(|e| format!("{}: {e}", file.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Links::default()),
|
||||
Err(e) => Err(format!("{}: {e}", file.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn link(dir: &Path, add: bool) -> Result<(), String> {
|
||||
let plugins = plugins_dir()?;
|
||||
let file = plugins.join("links.json");
|
||||
let mut links = read_links(&file)?;
|
||||
let dir = if add {
|
||||
let dir = canonical(dir)?;
|
||||
let m = Manifest::load(&dir)?;
|
||||
if let Some(wasm) = &m.wasm
|
||||
&& !dir.join(wasm).is_file()
|
||||
{
|
||||
println!("note: {} does not exist yet; run `openidea-plugin build` first", wasm.display());
|
||||
}
|
||||
if links.paths.contains(&dir) {
|
||||
println!("{} is already linked", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
println!("linked {} ({})", m.id, dir.display());
|
||||
links.paths.push(dir.clone());
|
||||
dir
|
||||
} else {
|
||||
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
let before = links.paths.len();
|
||||
links.paths.retain(|p| p != &dir);
|
||||
if links.paths.len() == before {
|
||||
return Err(format!("{} is not linked", dir.display()));
|
||||
}
|
||||
println!("unlinked {}", dir.display());
|
||||
dir
|
||||
};
|
||||
let _ = dir;
|
||||
std::fs::create_dir_all(&plugins).map_err(|e| format!("{}: {e}", plugins.display()))?;
|
||||
let json = serde_json::to_string_pretty(&links).map_err(|e| e.to_string())?;
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)
|
||||
.and_then(|()| std::fs::rename(&tmp, &file))
|
||||
.map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
println!("open Settings › Plugins in OpenIDEA to load the change");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_links() -> Result<(), String> {
|
||||
let links = read_links(&plugins_dir()?.join("links.json"))?;
|
||||
if links.paths.is_empty() {
|
||||
println!("no linked plugins");
|
||||
}
|
||||
for p in links.paths {
|
||||
let id = Manifest::load(&p).map(|m| m.id).unwrap_or_else(|e| format!("invalid: {e}"));
|
||||
println!("{id}\t{}", p.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- distribution
|
||||
|
||||
use ide_extension::package::{self, Index, IndexEntry, KeyEntry, PublisherKey};
|
||||
|
||||
fn read_key(path: &str) -> Result<PublisherKey, String> {
|
||||
PublisherKey::from_base64(&std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?)
|
||||
}
|
||||
|
||||
fn cmd_keygen(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &[])?;
|
||||
let [file] = a.positional.as_slice() else { return Err("usage: openidea-plugin keygen <key-file>".into()) };
|
||||
if Path::new(file).exists() {
|
||||
return Err(format!("{file} exists; not overwriting a key"));
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
getrandom::fill(&mut seed).map_err(|e| format!("random: {e}"))?;
|
||||
let key = PublisherKey::from_seed(seed);
|
||||
std::fs::write(file, key.seed_base64()).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
println!("public key: {}", key.public_base64());
|
||||
println!("register it with your publisher id in the marketplace index; keep {file} secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_endorse(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["key", "publisher"])?;
|
||||
let usage = "usage: openidea-plugin endorse <new-public-key> --key <earlier-key-file> --publisher ID";
|
||||
let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else {
|
||||
return Err(usage.into());
|
||||
};
|
||||
if !package::publisher_id_ok(publisher) {
|
||||
return Err(format!("invalid publisher id {publisher:?}"));
|
||||
}
|
||||
let endorsement = read_key(key)?.endorse(publisher, new.trim());
|
||||
println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fresh scratch directory, removed when dropped.
|
||||
fn scratch_dir(what: &str) -> Result<tempfile::TempDir, String> {
|
||||
tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A scratch host to list a package's files as the IDE would.
|
||||
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
|
||||
let tmp = scratch_dir("host")?;
|
||||
PluginHost::new(tmp.path().join("host"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|h| f(&h))
|
||||
}
|
||||
|
||||
fn cmd_pack(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["key", "publisher", "out"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
check(&dir)?;
|
||||
let manifest = Manifest::load(&dir)?;
|
||||
let mut files = with_scratch_host(|h| h.package_files_of(&dir).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&dir, &files).map_err(|e| e.to_string())?;
|
||||
let signature = match (a.value("key"), a.value("publisher")) {
|
||||
(Some(key), Some(publisher)) => {
|
||||
if !manifest.id.starts_with(&format!("{publisher}.")) {
|
||||
return Err(format!("publisher {publisher:?} may only sign plugins named {publisher}.*"));
|
||||
}
|
||||
Some(read_key(key)?.sign(publisher, &manifest.id, &manifest.version, &digest))
|
||||
}
|
||||
(None, None) => None,
|
||||
_ => return Err("--key and --publisher go together".into()),
|
||||
};
|
||||
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "NOTICE", "NOTICE.md", "COPYING", "README.md"] {
|
||||
if dir.join(lic).is_file() {
|
||||
files.push(PathBuf::from(lic));
|
||||
}
|
||||
}
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| {
|
||||
PathBuf::from(format!("{}-{}.{}", manifest.id, manifest.version, package::PACKAGE_EXTENSION))
|
||||
});
|
||||
package::pack(&dir, &files, &out, signature.as_ref())?;
|
||||
let size = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
let signed = signature.as_ref().map_or("unsigned".to_owned(), |s| format!("signed by {}", s.publisher));
|
||||
println!("-> {} ({}, {signed})", out.display(), human_size(size));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["publisher", "name", "key", "previous-key", "out", "download-base"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
|
||||
let base = a.value("download-base").map(|b| b.trim_end_matches('/'));
|
||||
// Revocations and other publishers of an existing index are kept.
|
||||
let mut index = match std::fs::read_to_string(&out) {
|
||||
Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
|
||||
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
|
||||
};
|
||||
let publisher = match (a.value("publisher"), a.value("key")) {
|
||||
(Some(publisher), Some(key)) => {
|
||||
let key = read_key(key)?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let previous = a.value("previous-key").map(read_key).transpose()?;
|
||||
index.publishers =
|
||||
register_key(std::mem::take(&mut index.publishers), publisher, name, &key, previous.as_ref())?;
|
||||
Some(publisher)
|
||||
}
|
||||
(None, None) if a.has("previous-key") => return Err("--previous-key goes with --publisher and --key".into()),
|
||||
(None, None) => None,
|
||||
_ => return Err("--publisher and --key go together".into()),
|
||||
};
|
||||
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
|
||||
.collect();
|
||||
packages.sort();
|
||||
let engine = PluginEngine::new()?;
|
||||
let mut added: Vec<IndexEntry> = Vec::new();
|
||||
for path in packages {
|
||||
let tmp = scratch_dir("index")?;
|
||||
let scratch = tmp.path().join("package");
|
||||
let entry = (|| {
|
||||
let unpacked = package::unpack(&path, &scratch)?;
|
||||
let m = Manifest::load(&scratch)?;
|
||||
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
|
||||
let sig = unpacked.signature.as_ref().ok_or("not signed")?;
|
||||
let signer = index.verify(sig, &m.id, &m.version, &digest)?;
|
||||
if publisher.is_some_and(|p| p != signer) {
|
||||
return Err(format!("signed by {signer}, not {}", publisher.unwrap_or_default()));
|
||||
}
|
||||
if added.iter().any(|e| (&e.id, &e.version) == (&m.id, &m.version)) {
|
||||
return Err(format!("another package of {} {} is in the folder", m.id, m.version));
|
||||
}
|
||||
if m.legacy {
|
||||
return Err("plugin.toml uses the 0.1 format; see plugins/README.md".into());
|
||||
}
|
||||
let built_for = match &m.wasm {
|
||||
Some(wasm) => engine.check_component(&engine.compile(&scratch.join(wasm))?)?,
|
||||
None => None,
|
||||
};
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok::<_, String>(IndexEntry {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
publisher: signer,
|
||||
download: base.map_or_else(|| file.clone(), |b| format!("{b}/{file}")),
|
||||
digest,
|
||||
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
|
||||
engine: package::engine_requirement(&m.engine, built_for.as_ref()),
|
||||
categories: m.categories(),
|
||||
})
|
||||
})();
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!(" {} {} (plugin API {})", entry.id, entry.version, entry.engine);
|
||||
added.push(entry);
|
||||
}
|
||||
// A publisher's earlier versions of these plugins stay for IDEs that can't load the new
|
||||
// ones (pruned below); plugins no longer in the folder go.
|
||||
index.plugins.retain(|e| match publisher {
|
||||
Some(p) => {
|
||||
e.publisher != p
|
||||
|| (added.iter().any(|n| n.id == e.id)
|
||||
&& !added.iter().any(|n| (&n.id, &n.version) == (&e.id, &e.version)))
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
index.plugins.extend(added);
|
||||
index.prune_versions();
|
||||
write_index(&index, &out)
|
||||
}
|
||||
|
||||
/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed
|
||||
/// (packages they signed remain valid until revoked); a key new to the
|
||||
/// publisher is endorsed by `previous`, one of them, so installed plugins
|
||||
/// signed with it update without asking.
|
||||
fn register_key(
|
||||
mut publishers: Vec<package::Publisher>,
|
||||
publisher: &str,
|
||||
name: &str,
|
||||
key: &PublisherKey,
|
||||
previous: Option<&PublisherKey>,
|
||||
) -> Result<Vec<package::Publisher>, String> {
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect();
|
||||
let entry = match earlier.iter().find(|k| k.key == current) {
|
||||
Some(listed) => listed.clone(),
|
||||
None => {
|
||||
let endorsement = match previous {
|
||||
Some(prev) => {
|
||||
let prev_key = prev.public_base64();
|
||||
if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) {
|
||||
return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index"));
|
||||
}
|
||||
Some(prev.endorse(publisher, ¤t))
|
||||
}
|
||||
None => {
|
||||
if earlier.iter().any(|k| !k.revoked) {
|
||||
println!(
|
||||
" warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \
|
||||
its plugins will be asked to confirm the change when they update"
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
KeyEntry { endorsement, ..KeyEntry::new(current.clone()) }
|
||||
}
|
||||
};
|
||||
let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect();
|
||||
publishers.retain(|p| p.id != publisher);
|
||||
publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
|
||||
Ok(publishers)
|
||||
}
|
||||
|
||||
/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
|
||||
fn current_format(index: Index, file: &str) -> Result<Index, String> {
|
||||
if index.is_newer() {
|
||||
return Err(format!(
|
||||
"{file}: index format {} is newer than this tool writes; update openidea-plugin",
|
||||
index.format
|
||||
));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn cmd_merge(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["keep", "out"])?;
|
||||
let [base, other] = a.positional.as_slice() else {
|
||||
return Err("usage: openidea-plugin merge <index> <other-index> --keep ID [--out FILE]".into());
|
||||
};
|
||||
let keep = a.value("keep").ok_or("--keep is required")?;
|
||||
let out = a.value("out").unwrap_or(base);
|
||||
let mut index =
|
||||
current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
|
||||
let bytes = read_market_url(other, 16 << 20)?;
|
||||
let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
|
||||
let mut theirs = current_format(theirs, other)?;
|
||||
// Their downloads are relative to their index, not to ours.
|
||||
for e in &mut theirs.plugins {
|
||||
e.download = resolve_market_href(other, &e.download);
|
||||
}
|
||||
let count = theirs.plugins.len();
|
||||
index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?;
|
||||
println!(" {count} plugins from {other}");
|
||||
write_index(&index, Path::new(out))
|
||||
}
|
||||
|
||||
fn write_index(index: &Index, out: &Path) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_key_is_endorsed_by_the_previous_one() {
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let first = register_key(Vec::new(), "acme", "ACME", &k1, None).unwrap();
|
||||
let rotated = register_key(first.clone(), "acme", "ACME", &k2, Some(&k1)).unwrap();
|
||||
let index = Index { format: package::INDEX_FORMAT, publishers: rotated.clone(), ..Index::default() };
|
||||
assert!(index.succeeds("acme", &k1.public_base64(), &k2.public_base64()));
|
||||
assert_eq!(rotated[0].keys.len(), 2, "the earlier key stays listed");
|
||||
// Registering the current key again keeps its endorsement.
|
||||
let again = register_key(rotated.clone(), "acme", "ACME", &k2, None).unwrap();
|
||||
assert_eq!(again, rotated);
|
||||
// Only a key the publisher has can endorse.
|
||||
assert!(register_key(first.clone(), "acme", "ACME", &k2, Some(&k3)).unwrap_err().contains("--previous-key"));
|
||||
// Without one the new key is listed unendorsed.
|
||||
let unendorsed = register_key(first, "acme", "ACME", &k3, None).unwrap();
|
||||
assert!(unendorsed[0].keys[0].endorsement.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
|
||||
assert_eq!(slugify("acme.Tools"), "acme.tools");
|
||||
assert_eq!(title_case("material-icons"), "Material Icons");
|
||||
assert_eq!(
|
||||
relative(Path::new("/a/b/plugins/x"), Path::new("/a/b/sdk/rust/p")).unwrap(),
|
||||
PathBuf::from("../../sdk/rust/p")
|
||||
);
|
||||
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_plugins_depend_on_the_sdk_by_path_only_in_its_source_tree() {
|
||||
let tree = dunce::canonicalize(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")).unwrap();
|
||||
assert_eq!(
|
||||
sdk_dependency(None, &tree.join("plugins/official/new")).unwrap(),
|
||||
"{ path = \"../../../sdk/rust/openidea-plugin\" }"
|
||||
);
|
||||
let outside = dunce::canonicalize(std::env::temp_dir()).unwrap().join("my-plugin");
|
||||
assert_eq!(
|
||||
sdk_dependency(None, &outside).unwrap(),
|
||||
format!("{{ git = \"https://github.com/l0ng-ai/openidea\", tag = \"v{}\" }}", env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
let sdk = tree.join("sdk/rust/openidea-plugin");
|
||||
assert!(sdk_dependency(Some(sdk.to_str().unwrap()), &outside).unwrap().starts_with("{ path = "));
|
||||
assert!(
|
||||
sdk_dependency(Some(tree.join("wit").to_str().unwrap()), &outside)
|
||||
.unwrap_err()
|
||||
.contains("not the openidea-plugin SDK")
|
||||
);
|
||||
}
|
||||
|
||||
/// A theme package `id` signed by `publisher` with `key`, packed into `out`.
|
||||
fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) {
|
||||
signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out);
|
||||
}
|
||||
|
||||
fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) {
|
||||
let src = tmp.join("src").join(format!("{id}-{version}"));
|
||||
let vars = templates::Vars { id, name: id, sdk: None };
|
||||
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
|
||||
let path = src.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let content = content
|
||||
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
|
||||
.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\""));
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(out).unwrap();
|
||||
let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.chain([out.join(format!("{id}-{version}.oiplugin")).to_string_lossy().into_owned()])
|
||||
.collect::<Vec<_>>();
|
||||
cmd_pack(&args).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_keeps_the_versions_older_ides_need() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
let key = t.join("acme.key");
|
||||
std::fs::write(&key, PublisherKey::from_seed([5; 32]).seed_base64()).unwrap();
|
||||
let key = key.to_string_lossy().into_owned();
|
||||
let index_file = t.join("index.json");
|
||||
let release = |n: usize, packages: &[(&str, &str, &str)]| {
|
||||
let dir = t.join(format!("release-{n}"));
|
||||
for (id, version, engine) in packages {
|
||||
signed_theme_version(t, id, version, engine, "acme", &key, &dir);
|
||||
}
|
||||
let base = format!("https://example.com/download/v{n}");
|
||||
let args = [dir.to_str().unwrap(), "--publisher", "acme", "--key", &key, "--download-base", &base];
|
||||
cmd_index(&strings(&[&args[..], &["--out", index_file.to_str().unwrap()]].concat())).unwrap();
|
||||
let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap();
|
||||
index.plugins.iter().map(|e| format!("{} {} {}", e.id, e.version, e.download)).collect::<Vec<_>>()
|
||||
};
|
||||
release(1, &[("acme.a", "1.0.0", "^1.0"), ("acme.b", "1.0.0", "^1.0")]);
|
||||
// Same requirement: the newer version replaces it; acme.b is gone from the release.
|
||||
assert_eq!(
|
||||
release(2, &[("acme.a", "1.1.0", "^1.0")]),
|
||||
["acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin"]
|
||||
);
|
||||
// Another API requirement: the last version for the earlier one stays, where it was published.
|
||||
assert_eq!(
|
||||
release(3, &[("acme.a", "2.0.0", ">=1.0, <2")]),
|
||||
[
|
||||
"acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin",
|
||||
"acme.a 2.0.0 https://example.com/download/v3/acme.a-2.0.0.oiplugin",
|
||||
]
|
||||
);
|
||||
let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap();
|
||||
assert_eq!(index.offered()[0].0.version, "2.0.0", "the newest this IDE can load");
|
||||
}
|
||||
|
||||
fn strings(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_and_community_indexes_merge() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
let mut keys = Vec::new();
|
||||
for (name, seed) in [("openidea", 1), ("alice", 2), ("carol", 3)] {
|
||||
let file = t.join(format!("{name}.key"));
|
||||
let key = PublisherKey::from_seed([seed; 32]);
|
||||
std::fs::write(&file, key.seed_base64()).unwrap();
|
||||
keys.push((file.to_string_lossy().into_owned(), key.public_base64()));
|
||||
}
|
||||
let (official, community) = (t.join("official"), t.join("community"));
|
||||
signed_theme(t, "openidea.night", "openidea", &keys[0].0, &official);
|
||||
signed_theme(t, "alice.dawn", "alice", &keys[1].0, &community);
|
||||
let official_index = official.join("index.json");
|
||||
let community_index = community.join("index.json");
|
||||
let release = strings(&[official.to_str().unwrap(), "--publisher", "openidea", "--key", &keys[0].0]);
|
||||
cmd_index(&release).unwrap();
|
||||
|
||||
// The community index lists its publishers; `index` without a key checks packages against them.
|
||||
let skeleton = Index {
|
||||
format: package::INDEX_FORMAT,
|
||||
publishers: vec![package::Publisher {
|
||||
id: "alice".into(),
|
||||
name: "Alice".into(),
|
||||
keys: vec![KeyEntry::new(keys[1].1.clone())],
|
||||
}],
|
||||
..Index::default()
|
||||
};
|
||||
write_index(&skeleton, &community_index).unwrap();
|
||||
cmd_index(&strings(&[community.to_str().unwrap()])).unwrap();
|
||||
|
||||
cmd_merge(&strings(&[
|
||||
official_index.to_str().unwrap(),
|
||||
community_index.to_str().unwrap(),
|
||||
"--keep",
|
||||
"openidea",
|
||||
]))
|
||||
.unwrap();
|
||||
// A release rebuilds the official plugins and keeps the community's.
|
||||
cmd_index(&release).unwrap();
|
||||
let index = Index::parse(&std::fs::read_to_string(&official_index).unwrap()).unwrap();
|
||||
let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect();
|
||||
assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]);
|
||||
assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme");
|
||||
assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin");
|
||||
assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download);
|
||||
|
||||
// A package by a publisher the index doesn't know is refused.
|
||||
signed_theme(t, "carol.noon", "carol", &keys[2].0, &community);
|
||||
let err = cmd_index(&strings(&[community.to_str().unwrap()])).unwrap_err();
|
||||
assert!(err.contains("unknown publisher"), "{err}");
|
||||
}
|
||||
|
||||
/// `cargo test` of a new Rust plugin (its own tests against the mock host) with this SDK.
|
||||
#[test]
|
||||
fn the_command_template_builds_against_the_sdk() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-plugin-template-build-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dir = dunce::canonicalize(&dir).unwrap();
|
||||
let plugin = dir.join("plugin");
|
||||
let sdk = concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin");
|
||||
let sdk = sdk_dependency(Some(sdk), &plugin).unwrap();
|
||||
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some(&sdk) };
|
||||
for (rel, content) in templates::render(templates::Template::Command, &vars) {
|
||||
let path = plugin.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
// The SDK's lockfile keeps the versions its own CI builds with (the resolver also
|
||||
// looks up the wasm-only dependencies, which may not be downloaded yet).
|
||||
let sdk_lock = concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin/Cargo.lock");
|
||||
std::fs::copy(sdk_lock, plugin.join("Cargo.lock")).unwrap();
|
||||
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
|
||||
.args(["test", "--manifest-path"])
|
||||
.arg(plugin.join("Cargo.toml"))
|
||||
.env("CARGO_TARGET_DIR", dir.join("target"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_template_is_a_valid_package() {
|
||||
for t in templates::Template::ALL {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("{ path = \"../sdk\" }") };
|
||||
for (rel, content) in templates::render(t, &vars) {
|
||||
let path = dir.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
let m = Manifest::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
|
||||
assert_eq!(m.id, "acme.sample");
|
||||
if m.wasm.is_none() {
|
||||
check(&dir).unwrap_or_else(|e| panic!("{}: {e}", t.label()));
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! The files of `openidea-plugin new`. Placeholders: `__ID__` (plugin id),
|
||||
//! `__SLUG__` (last id segment), `__NAME__`, `__CRATE__`, `__SDK__`.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Template {
|
||||
Command,
|
||||
Theme,
|
||||
IconTheme,
|
||||
Language,
|
||||
}
|
||||
|
||||
impl Template {
|
||||
pub const ALL: [Template; 4] = [Self::Command, Self::Theme, Self::IconTheme, Self::Language];
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|t| t.label() == s)
|
||||
.ok_or_else(|| format!("unknown template {s:?} (command, theme, icon-theme, language)"))
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Command => "command",
|
||||
Self::Theme => "theme",
|
||||
Self::IconTheme => "icon-theme",
|
||||
Self::Language => "language",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Vars<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: &'a str,
|
||||
/// The SDK dependency, as a TOML value (`{ path = "…" }`).
|
||||
pub sdk: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `(relative path, contents)` of every file of the template.
|
||||
pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
|
||||
let slug = vars.id.rsplit('.').next().unwrap_or(vars.id).to_owned();
|
||||
let crate_name = format!("openidea-plugin-{}", vars.id.replace('.', "-"));
|
||||
let fill = |s: &str| {
|
||||
s.replace("__ID__", vars.id)
|
||||
.replace("__SLUG__", &slug)
|
||||
.replace("__NAME__", vars.name)
|
||||
.replace("__CRATE__", &crate_name)
|
||||
.replace("__SDK__", vars.sdk.unwrap_or(""))
|
||||
// The theme files come from the Harbor Night plugin.
|
||||
.replace("harbor-night", &slug)
|
||||
.replace("Harbor Night", vars.name)
|
||||
};
|
||||
let files: Vec<(String, &str)> = match template {
|
||||
Template::Command => vec![
|
||||
("plugin.toml".into(), COMMAND_MANIFEST),
|
||||
("Cargo.toml".into(), COMMAND_CARGO),
|
||||
("src/lib.rs".into(), COMMAND_LIB),
|
||||
(".gitignore".into(), "/target\n"),
|
||||
("README.md".into(), COMMAND_README),
|
||||
],
|
||||
Template::Theme => vec![
|
||||
("plugin.toml".into(), THEME_MANIFEST),
|
||||
(format!("themes/{slug}.theme.json"), THEME_UI),
|
||||
(format!("themes/{slug}.scheme.json"), THEME_SCHEME),
|
||||
],
|
||||
Template::IconTheme => vec![
|
||||
("plugin.toml".into(), ICON_MANIFEST),
|
||||
("icons/theme.json".into(), ICON_THEME),
|
||||
("icons/file.svg".into(), ICON_FILE),
|
||||
("icons/folder.svg".into(), ICON_FOLDER),
|
||||
("icons/folder-open.svg".into(), ICON_FOLDER_OPEN),
|
||||
("icons/markdown.svg".into(), ICON_MARKDOWN),
|
||||
],
|
||||
Template::Language => {
|
||||
vec![("plugin.toml".into(), LANGUAGE_MANIFEST), (format!("languages/{slug}.toml"), LANGUAGE_DEF)]
|
||||
}
|
||||
};
|
||||
files.into_iter().map(|(path, content)| (path, fill(content))).collect()
|
||||
}
|
||||
|
||||
const COMMAND_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Adds two sample commands."
|
||||
authors = []
|
||||
# Marketplace categories (up to 3): languages, themes, icon-themes, linters,
|
||||
# vcs, ai, frameworks, tools, other.
|
||||
categories = ["tools"]
|
||||
engines.openidea = "^1.0"
|
||||
# Built by `openidea-plugin build`. The code is loaded the first time one of
|
||||
# its commands runs; the commands below show in Find Action before that.
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
# What the code may do. The user approves these when installing.
|
||||
[capabilities]
|
||||
required = ["editor.read"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "say-hello"
|
||||
title = "__NAME__: Say Hello"
|
||||
category = "__NAME__"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "line-count"
|
||||
title = "__NAME__: Line Count of Active File"
|
||||
category = "__NAME__"
|
||||
"#;
|
||||
|
||||
const COMMAND_CARGO: &str = r#"[package]
|
||||
name = "__CRATE__"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = __SDK__
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
"#;
|
||||
|
||||
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
|
||||
|
||||
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Main {
|
||||
greetings: u32,
|
||||
}
|
||||
|
||||
impl Plugin for Main {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"say-hello" => {
|
||||
// Instance state lasts until the plugin is idle for a while;
|
||||
// keep what must survive in `host::storage`.
|
||||
self.greetings += 1;
|
||||
let body = format!("Hello #{} from __NAME__!", self.greetings);
|
||||
host::notify(NotificationLevel::Info, "__NAME__", &body);
|
||||
Ok(None)
|
||||
}
|
||||
"line-count" => {
|
||||
let editor = ctx.editor().ok_or("no file is open")?;
|
||||
let text = editor.document()?.text;
|
||||
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
|
||||
}
|
||||
_ => Err(format!("unknown command {id}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Main);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openidea_plugin::mock::MockHost;
|
||||
|
||||
#[test]
|
||||
fn says_hello() {
|
||||
let host = MockHost::install();
|
||||
let mut plugin = Main::default();
|
||||
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
|
||||
plugin.run_command("say-hello", &host.invocation(), None).unwrap();
|
||||
assert_eq!(host.notifications()[1].body, "Hello #2 from __NAME__!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_lines() {
|
||||
let host = MockHost::install();
|
||||
host.open_editor("/project/src/main.rs", "rust", "fn main() {\n}\n", &[]);
|
||||
let out = Main::default().run_command("line-count", &host.invocation(), None).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("2 lines in /project/src/main.rs"));
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const COMMAND_README: &str = r#"# __NAME__
|
||||
|
||||
An OpenIDEA plugin.
|
||||
|
||||
```sh
|
||||
cargo test # logic, against the SDK's mock host
|
||||
openidea-plugin build # → dist/plugin.wasm, validated like an install
|
||||
openidea-plugin link # load this folder in the IDE (Settings › Plugins)
|
||||
```
|
||||
|
||||
`plugin.toml` declares the commands, so they appear in Find Action before the
|
||||
code is loaded, and the capabilities the code needs. `src/lib.rs` implements
|
||||
them with the `openidea-plugin` SDK.
|
||||
"#;
|
||||
|
||||
const THEME_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "A dark theme."
|
||||
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: docs/plugins/themes.md.
|
||||
[[contributes.themes]]
|
||||
path = "themes/__SLUG__.theme.json"
|
||||
editor-scheme = "themes/__SLUG__.scheme.json"
|
||||
"#;
|
||||
|
||||
const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json");
|
||||
const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json");
|
||||
|
||||
const ICON_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "File and folder icons."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# Apply it in Settings › Plugins › __NAME__. SVGs are sanitised on install:
|
||||
# no scripts, event handlers, external references or entities.
|
||||
[[contributes.iconThemes]]
|
||||
id = "__SLUG__"
|
||||
label = "__NAME__"
|
||||
path = "icons/theme.json"
|
||||
"#;
|
||||
|
||||
const ICON_THEME: &str = r#"{
|
||||
"iconDefinitions": {
|
||||
"file": "icons/file.svg",
|
||||
"folder": "icons/folder.svg",
|
||||
"folder-open": "icons/folder-open.svg",
|
||||
"markdown": "icons/markdown.svg"
|
||||
},
|
||||
"file": "file",
|
||||
"folder": "folder",
|
||||
"folderExpanded": "folder-open",
|
||||
"fileExtensions": { "md": "markdown", "markdown": "markdown" },
|
||||
"fileNames": { "readme": "markdown" },
|
||||
"folderNames": {}
|
||||
}
|
||||
"#;
|
||||
|
||||
const ICON_FILE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M4 1.5h5.5L13 5v9.5H4z"/><path fill="#cfd8dc" d="M9.5 1.5V5H13z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_FOLDER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6.5v9h-13z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_FOLDER_OPEN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#90a4ae" d="M1.5 3h5l1.5 1.5h6v2h-10l-2 7h-.5z"/><path fill="#b0bec5" d="M4 7.5h11.5l-2 6H2z"/></svg>
|
||||
"##;
|
||||
|
||||
const ICON_MARKDOWN: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="3.5" width="14" height="9" rx="1.5" fill="#42a5f5"/><path fill="#fff" d="M3 10.5v-5h1.5L6 7.5l1.5-2H9v5H7.5V8L6 10 4.5 8v2.5zm8 0L9.5 8h1V5.5H12V8h1z"/></svg>
|
||||
"##;
|
||||
|
||||
const LANGUAGE_MANIFEST: &str = r#"id = "__ID__"
|
||||
name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Language support for .__SLUG__ files: detection, comments and indentation."
|
||||
authors = []
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
# Same format as languages/<id>.toml in the IDE repository.
|
||||
[[contributes.languages]]
|
||||
path = "languages/__SLUG__.toml"
|
||||
"#;
|
||||
|
||||
const LANGUAGE_DEF: &str = r##"id = "__SLUG__"
|
||||
name = "__NAME__"
|
||||
extensions = ["__SLUG__"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
# Borrow an existing CodeMirror mode for instant highlighting.
|
||||
ui-mode = "Properties files"
|
||||
"##;
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `openidea-server --stdio`: the IDE backend for remote development, serving
|
||||
//! one desktop client over stdin / stdout (started by the client over SSH).
|
||||
|
||||
use ide_host::plugins::remote::{RemotePluginLanguages, SYNC_COMMAND};
|
||||
use ide_host::plugins::remote_host::RemotePlugins;
|
||||
use ide_host::rpc::{self, Dispatcher};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_language::LanguageRegistry;
|
||||
@@ -51,8 +53,12 @@ async fn run(base: PathBuf) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
// The desktop's plugin languages (`plugin_sync_languages`); programs pinned on this machine.
|
||||
let plugin_languages = RemotePluginLanguages::new(Some(&data_dir));
|
||||
languages.attach_plugins(plugin_languages.store().clone());
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer.clone();
|
||||
let plugins_data = data_dir.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
local_data_dir: data_dir.clone(),
|
||||
@@ -64,8 +70,31 @@ async fn run(base: PathBuf) -> i32 {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: true,
|
||||
}));
|
||||
// The desktop's code plugins run here, next to the project (`plugin_sync_packages`).
|
||||
let plugins = match RemotePlugins::start(&plugins_data, &host, peer.clone()) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
tracing::warn!("plugins are unavailable: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.register(&mut dispatcher);
|
||||
}
|
||||
dispatcher.add(SYNC_COMMAND, move |c| {
|
||||
let remote = plugin_languages.clone();
|
||||
async move {
|
||||
let report = remote.sync(c.arg("languages")?);
|
||||
c.host.plugin_languages_changed();
|
||||
Ok(report)
|
||||
}
|
||||
});
|
||||
rpc::serve(host.clone(), dispatcher, peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
if let Some(plugins) = &plugins {
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
// `process::exit` skips destructors: nothing started for the client may outlive it.
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::Host;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit};
|
||||
use ide_language::LanguageConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
@@ -11,20 +11,27 @@ use std::path::Path;
|
||||
|
||||
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
|
||||
|
||||
fn supports_external_format(language: &LanguageConfig, tool: ExternalFormatter) -> bool {
|
||||
match language.formatter.as_ref().map(|f| f.tool.as_str()) {
|
||||
Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt),
|
||||
Some("ruff") => tool == ExternalFormatter::Ruff,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The external formatter of a file, for a language with an external formatter.
|
||||
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
|
||||
/// applies when it is enabled for the language and either an oxfmt
|
||||
/// configuration is found above the file or oxfmt is the selected provider;
|
||||
/// else Prettier, when it is enabled. Turning Prettier off for a language
|
||||
/// leaves it to the language server, not to oxfmt.
|
||||
/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is
|
||||
/// the base formatter for Python; oxfmt can replace Prettier for its languages
|
||||
/// when configured or selected. A tool that cannot format the language is
|
||||
/// rejected even when explicitly requested.
|
||||
fn external_tool(
|
||||
forced: Option<ExternalFormatter>,
|
||||
language: Option<&LanguageConfig>,
|
||||
file: &Path,
|
||||
settings: &LanguagesSettings,
|
||||
) -> IdeResult<Option<ExternalFormatter>> {
|
||||
use ExternalFormatter::{Oxfmt, Prettier};
|
||||
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
|
||||
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
|
||||
let Some(language) = language.filter(|l| l.formatter.is_some()) else {
|
||||
return match forced {
|
||||
Some(tool) => Err(IdeError::invalid(format!(
|
||||
"{} cannot format {} files",
|
||||
@@ -34,10 +41,21 @@ fn external_tool(
|
||||
None => Ok(None),
|
||||
};
|
||||
};
|
||||
if forced.is_some() {
|
||||
return Ok(forced);
|
||||
if let Some(tool) = forced {
|
||||
return if supports_external_format(language, tool) {
|
||||
Ok(Some(tool))
|
||||
} else {
|
||||
Err(IdeError::invalid(format!(
|
||||
"{} cannot format {} files",
|
||||
formatter::display_name(tool.name()),
|
||||
language.name
|
||||
)))
|
||||
};
|
||||
}
|
||||
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
|
||||
if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") {
|
||||
return Ok(enabled(Ruff).then_some(Ruff));
|
||||
}
|
||||
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
|
||||
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
|
||||
Some(Oxfmt)
|
||||
@@ -46,6 +64,61 @@ fn external_tool(
|
||||
})
|
||||
}
|
||||
|
||||
fn codepoint_col(line: &str, utf16_col: u32) -> u32 {
|
||||
let mut units = 0;
|
||||
for (index, ch) in line.chars().enumerate() {
|
||||
if units >= utf16_col {
|
||||
return index as u32;
|
||||
}
|
||||
units += ch.len_utf16() as u32;
|
||||
}
|
||||
line.chars().count() as u32
|
||||
}
|
||||
|
||||
fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange {
|
||||
let position = |pos: Pos| {
|
||||
let line = text.line(pos.line).unwrap_or_default();
|
||||
(pos.line + 1, codepoint_col(&line, pos.col) + 1)
|
||||
};
|
||||
let (start_line, start_col) = position(range.start);
|
||||
let (end_line, end_col) = position(range.end);
|
||||
formatter::FormatRange {
|
||||
start_offset: text.pos_to_offset(range.start),
|
||||
end_offset: text.pos_to_offset(range.end),
|
||||
start_line,
|
||||
start_col,
|
||||
end_line,
|
||||
end_col,
|
||||
}
|
||||
}
|
||||
|
||||
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
|
||||
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
|
||||
fn php_cs_fixer_edits(
|
||||
tools: &Tools,
|
||||
php: Option<&Path>,
|
||||
fixer: &formatter::PhpCsFixer,
|
||||
path: &Path,
|
||||
unsaved: &str,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
|
||||
let dir = tempfile::tempdir()?;
|
||||
let copy = dir.path().join(name);
|
||||
std::fs::write(©, unsaved)?;
|
||||
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, ©)?, &Default::default())?;
|
||||
let formatted = std::fs::read_to_string(©)?;
|
||||
let text = ide_text::Document::new(unsaved);
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -57,6 +130,34 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let edits = tokio::task::spawn_blocking(move || {
|
||||
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if range.is_some() {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "PHP-CS-Fixer".into(),
|
||||
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
|
||||
});
|
||||
}
|
||||
return Ok(edits);
|
||||
}
|
||||
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
|
||||
if let Some(tool) = selected
|
||||
&& options.tool.is_none()
|
||||
@@ -85,13 +186,28 @@ pub async fn format(
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
|
||||
let tools = Tools::new(&host.local_data_dir);
|
||||
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
|
||||
let project_root = project.root.clone();
|
||||
let configuration_directory = settings.languages.ruff.configuration_directory.clone();
|
||||
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
let range = range.map(|range| format_range(&text, range));
|
||||
let config_file = if tool == "ruff" {
|
||||
ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
|
||||
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
|
||||
command.current_dir(dir);
|
||||
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
command.args(formatter::arguments(
|
||||
tool,
|
||||
&config,
|
||||
&snap.path,
|
||||
options.tab_size,
|
||||
options.insert_spaces,
|
||||
range,
|
||||
config_file.as_deref(),
|
||||
));
|
||||
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
@@ -120,7 +236,7 @@ pub async fn format(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ExternalFormatter::{Oxfmt, Prettier};
|
||||
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
|
||||
use ide_core::LanguageId;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
@@ -135,17 +251,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_give_every_external_formatter_language_to_prettier() {
|
||||
fn default_settings_use_ruff_for_python_and_prettier_for_the_web() {
|
||||
let settings = LanguagesSettings::default();
|
||||
let file = Path::new("/project/file");
|
||||
for lang in LanguageRegistry::load(None).unwrap().all() {
|
||||
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
|
||||
let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) {
|
||||
Some("prettier") => Some(Prettier),
|
||||
Some("ruff") => Some(Ruff),
|
||||
_ => None,
|
||||
};
|
||||
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
|
||||
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
|
||||
assert_eq!(selected, configured, "{}", lang.id);
|
||||
}
|
||||
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_is_the_python_external_formatter_and_is_opt_out() {
|
||||
let file = Path::new("/project/src/example.py");
|
||||
let mut settings = LanguagesSettings::default();
|
||||
assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff));
|
||||
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
|
||||
settings.ruff.languages.clear();
|
||||
assert_eq!(tool(None, "python", file, &settings).unwrap(), None);
|
||||
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
|
||||
let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err();
|
||||
assert_eq!(prettier.message, "Prettier cannot format Python files");
|
||||
let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err();
|
||||
assert_eq!(oxfmt.message, "oxfmt cannot format Python files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -192,4 +327,74 @@ mod tests {
|
||||
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
|
||||
assert_eq!(rust.message, "oxfmt cannot format Rust files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("src/Http")).unwrap();
|
||||
let file = root.join("src/Http/Kernel.php");
|
||||
assert_eq!(formatter::php_cs_fixer(&file), None);
|
||||
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
|
||||
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
|
||||
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
|
||||
let found = formatter::php_cs_fixer(&file).unwrap();
|
||||
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
|
||||
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
|
||||
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
|
||||
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
|
||||
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
|
||||
std::fs::write(&fixer.config, "<?php").unwrap();
|
||||
let php = root.join("php");
|
||||
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
|
||||
std::fs::write(
|
||||
&php,
|
||||
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
|
||||
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let file = root.join("src/Kernel.php");
|
||||
let tools = Tools::new(&root.join("tools"));
|
||||
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, " ");
|
||||
assert!(!file.exists(), "the file on disk is left alone");
|
||||
|
||||
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
|
||||
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
|
||||
assert!(
|
||||
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
|
||||
"{}",
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_ranges_are_one_based_unicode_columns() {
|
||||
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
|
||||
let range = Range::new(Pos::new(0, 2), Pos::new(1, 2));
|
||||
assert_eq!(
|
||||
format_range(&text, range),
|
||||
formatter::FormatRange {
|
||||
start_offset: 2,
|
||||
end_offset: 10,
|
||||
start_line: 1,
|
||||
start_col: 2,
|
||||
end_line: 2,
|
||||
end_col: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+202
-55
@@ -1,6 +1,6 @@
|
||||
//! The IDE backend without a UI toolkit: open projects per window, terminals,
|
||||
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
|
||||
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
|
||||
//! wraps one [`Host`] and adds windows and menus; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod data_dirs;
|
||||
@@ -10,6 +10,8 @@ mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod phpstan;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
mod requests;
|
||||
@@ -24,7 +26,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -73,15 +75,25 @@ pub struct Host {
|
||||
pub environment: environment::Environment,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
proxy_session_password: Mutex<Option<String>>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
/// One open at a time per window (see [`Host::opening`]).
|
||||
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
@@ -101,6 +113,7 @@ impl Host {
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
@@ -121,8 +134,11 @@ impl Host {
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -133,6 +149,17 @@ impl Host {
|
||||
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
|
||||
}
|
||||
|
||||
/// Plugin languages or the servers they may start changed (they are part
|
||||
/// of [`Self::languages`] and every project's fork): open documents are
|
||||
/// detected again and language servers follow their new configuration.
|
||||
pub fn plugin_languages_changed(&self) {
|
||||
for project in self.projects() {
|
||||
project.documents.redetect_languages();
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
@@ -165,45 +192,78 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes `window`'s project and waits until its language servers, kernels and
|
||||
/// debug sessions have shut down. Never call this on the UI event-loop thread:
|
||||
/// the shutdown runs on the async runtime, whose workers may themselves be
|
||||
/// waiting for the event loop (IPC responses, window queries). Use
|
||||
/// [`Self::close_project_detached`] there.
|
||||
pub fn close_project(&self, window: &str) {
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
);
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_project_workers_blocking(
|
||||
/// Like [`Self::close_project`], but returns once the project is detached from
|
||||
/// the window; its workers finish shutting down in the background, and
|
||||
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
|
||||
pub fn close_project_detached(&self, window: &str) {
|
||||
if let Some(shutdown) = self.begin_close(window) {
|
||||
let mut closing = self.closing.lock();
|
||||
closing.retain(|handle| !handle.is_finished());
|
||||
closing.push(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detaches the project from `window` and stops what it started, synchronously,
|
||||
/// so the window can open another project at once. Returns the thread that
|
||||
/// finishes shutting down its workers.
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), None);
|
||||
plugins.set_active_file(window, None);
|
||||
}
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
// What the closing window started goes with it — the builds and searches it
|
||||
// asked for, its runs, its shells and its debug sessions — and its language
|
||||
// servers shut down.
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
Some(self.spawn_project_shutdown(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
Some(project),
|
||||
))
|
||||
}
|
||||
|
||||
/// Project/window destruction must finish owned kernel cleanup, not enqueue
|
||||
/// work which can be lost as the desktop runtime exits: the returned thread is
|
||||
/// joined by the closer or, for a detached close, before exit.
|
||||
fn spawn_project_shutdown(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
lsp: Option<Arc<ide_lsp::LspManager>>,
|
||||
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
|
||||
) {
|
||||
retired: Option<Arc<Project>>,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
@@ -211,25 +271,44 @@ impl Host {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
// Dropping the project stops its file watcher and index worker, which first finish the
|
||||
// batch they are applying (many file events while a build writes its output), so it
|
||||
// isn't done on the closing caller's thread (the UI thread for a window close).
|
||||
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
|
||||
runtime.block_on(async move {
|
||||
for bridge in bridges {
|
||||
bridge.shutdown().await;
|
||||
}
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
if let Some(lsp) = lsp {
|
||||
lsp.shutdown_all().await;
|
||||
}
|
||||
if let Some((debug, key)) = debug {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
if let Some(retired) = retired {
|
||||
let _ = retired.join();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for detached closes, so a project opened next never races the
|
||||
/// language servers of one still shutting down (e.g. over a shared workspace).
|
||||
pub(crate) fn wait_detached_closes(&self) {
|
||||
let pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
for shutdown in pending {
|
||||
self.wait_blocking(shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
|
||||
let wait = move || {
|
||||
if let Err(error) = shutdown.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
@@ -241,15 +320,33 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
/// Called before the desktop runtime exits, including update/restart exits, on
|
||||
/// the event-loop thread: waits for detached closes and the open projects'
|
||||
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
|
||||
/// stuck on the event loop cannot keep the process from exiting.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
let projects = self.projects();
|
||||
self.shutdown_project_workers_blocking(
|
||||
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
|
||||
pending.push(self.spawn_project_shutdown(
|
||||
projects.iter().map(|project| project.notebooks.clone()).collect(),
|
||||
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
None,
|
||||
));
|
||||
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
|
||||
while pending.iter().any(|handle| !handle.is_finished()) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
for handle in pending {
|
||||
if let Err(error) = handle.join() {
|
||||
tracing::error!("project shutdown thread failed: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
@@ -270,6 +367,9 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
@@ -299,14 +399,20 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
let token = self.begin_project_read_request(window, id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
/// Read-only work remains cancellable/disposed with its window in restricted projects.
|
||||
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
@@ -319,7 +425,48 @@ impl Host {
|
||||
self.requests.cancel(id);
|
||||
}
|
||||
|
||||
/// The lock an open of `window` holds from its check for the current project to its
|
||||
/// insert: two opens racing (a double click, a launch path and a restore) would each start
|
||||
/// an index, language servers and watchers, and the second would replace the first unclosed.
|
||||
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
|
||||
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn detached_close_releases_the_project_before_a_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
|
||||
host.close_project_detached("main");
|
||||
assert!(host.project("main").is_err());
|
||||
// The project (its file watcher and index database) is dropped off the closing thread, but
|
||||
// before opening a project waits out the close.
|
||||
host.wait_detached_closes();
|
||||
assert!(project.upgrade().is_none(), "the closed project is dropped");
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.close_project("main");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Project-incarnation notebook ownership and whole-batch execution admission.
|
||||
use crate::ops::Channel;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, SharedSink};
|
||||
use ide_notebook::kernel::{KernelManager, KernelOutput};
|
||||
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
|
||||
use parking_lot::Mutex;
|
||||
@@ -17,7 +17,7 @@ use tokio::sync::{Mutex as AsyncMutex, watch};
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookOutputEvent {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub path: IpcPath,
|
||||
pub cell: String,
|
||||
pub msg_id: String,
|
||||
pub kind: String,
|
||||
@@ -536,7 +536,7 @@ fn commit_output(
|
||||
};
|
||||
Ok(Some(NotebookOutputEvent {
|
||||
id: document.id.clone(),
|
||||
path: document.path.to_string_lossy().into_owned(),
|
||||
path: document.path.clone().into(),
|
||||
cell: cell.to_owned(),
|
||||
msg_id: output.msg_id,
|
||||
kind: output.kind,
|
||||
@@ -592,6 +592,16 @@ mod tests {
|
||||
other.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_notebook_path_reaches_the_ui_as_the_project_tree_names_it() {
|
||||
let (dir, service, _) = fixture();
|
||||
let ui_path =
|
||||
format!("{}/session.ipynb", ide_core::ipc_path::to_ipc(&dunce::canonicalize(dir.path()).unwrap()));
|
||||
let snapshot = service.open(Path::new(&ui_path)).unwrap();
|
||||
assert_eq!(serde_json::to_value(&snapshot).unwrap()["path"], ui_path);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
|
||||
let (dir, service, initial) = fixture();
|
||||
|
||||
@@ -8,8 +8,11 @@ use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_tools::InstallEvent;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
/// Streams the project's agent events to this window; returns every task.
|
||||
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
|
||||
@@ -30,6 +33,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option<String
|
||||
host.project(window)?.agents.set_builtin_command(&id, command)
|
||||
}
|
||||
|
||||
/// Environment variables built-in agent `id` starts with; empty clears them.
|
||||
pub fn set_env(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_builtin_env(&id, env)
|
||||
}
|
||||
|
||||
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
|
||||
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
|
||||
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
|
||||
@@ -47,7 +61,39 @@ pub async fn registry_list(host: &Host, _window: &str, request: RequestId) -> Id
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_agent::registry::list(&paths, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
let mut registry = result??;
|
||||
let installing = installing();
|
||||
for agent in &mut registry.agents {
|
||||
agent.installing = installing.contains(&agent.id);
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
/// Registry installs in progress, by agent id. An install outlives the Settings page
|
||||
/// that started it, so a second one of the same agent is refused rather than racing it
|
||||
/// for the same folder.
|
||||
static INSTALLING: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn installing() -> std::sync::MutexGuard<'static, HashSet<String>> {
|
||||
INSTALLING.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Marks agent `id` as installing until dropped.
|
||||
struct Installing(String);
|
||||
|
||||
impl Installing {
|
||||
fn begin(id: &str) -> IdeResult<Self> {
|
||||
if !installing().insert(id.to_owned()) {
|
||||
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{id} is already being installed")));
|
||||
}
|
||||
Ok(Self(id.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Installing {
|
||||
fn drop(&mut self) {
|
||||
installing().remove(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the registry's current version of agent `id` (also to update it), streaming
|
||||
@@ -61,11 +107,43 @@ pub async fn registry_install(
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let paths = registry_paths(host);
|
||||
let installing = Installing::begin(&id)?;
|
||||
let cancel = host.begin_request(request);
|
||||
// Also a background task of every project window, so it stays visible and can be
|
||||
// stopped there once the Settings page is closed.
|
||||
let task = TaskId::next();
|
||||
let title = format!("Installing {id}");
|
||||
let sinks: Vec<SharedSink> = host.projects().iter().map(|p| p.sink.clone()).collect();
|
||||
let report = move |message: Option<String>, fraction: Option<f32>, done: bool| {
|
||||
for sink in &sinks {
|
||||
sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
title: title.clone(),
|
||||
message: message.clone(),
|
||||
fraction,
|
||||
done,
|
||||
cancellable: !done,
|
||||
});
|
||||
}
|
||||
};
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
let _installing = installing;
|
||||
let _cancel = ide_core::task::on_cancel(task, {
|
||||
let cancel = cancel.clone();
|
||||
move || cancel.cancel()
|
||||
});
|
||||
report(None, None, false);
|
||||
let result = ide_agent::registry::install(&paths, &id, &cancel, &|e| {
|
||||
match &e {
|
||||
InstallEvent::Line { text } => report(Some(text.clone()), None, false),
|
||||
InstallEvent::Progress { fraction, message } => {
|
||||
report(Some(message.clone()), fraction.map(|f| f as f32), false)
|
||||
}
|
||||
}
|
||||
let _ = on_event.send(e);
|
||||
})
|
||||
});
|
||||
report(None, Some(1.0), true);
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -195,6 +273,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
|
||||
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
|
||||
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_registry_install", |c| async move {
|
||||
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
@@ -251,6 +330,20 @@ fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::P
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_agent_installs_once_at_a_time() {
|
||||
let first = Installing::begin("test-agent").unwrap();
|
||||
assert!(installing().contains("test-agent"));
|
||||
let again = Installing::begin("test-agent").err().unwrap();
|
||||
assert_eq!(again.kind, ErrorKind::AlreadyExists);
|
||||
let other = Installing::begin("test-other-agent").unwrap();
|
||||
drop(first);
|
||||
assert!(!installing().contains("test-agent"));
|
||||
drop(Installing::begin("test-agent").unwrap());
|
||||
drop(other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_roots_are_exact_window_or_workspace_members() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
cache,
|
||||
ide_sdk::apply::maven_local_repository(root, settings),
|
||||
&settings.languages.java.maven,
|
||||
&settings.languages.java.gradle,
|
||||
);
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
@@ -45,7 +46,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
for g in &mut model.gradle {
|
||||
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
|
||||
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
|
||||
}
|
||||
@@ -65,11 +66,12 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
for g in &mut model.gradle {
|
||||
if g.sync_error.is_some() {
|
||||
let hint =
|
||||
if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None };
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
}
|
||||
model
|
||||
}
|
||||
@@ -94,9 +96,89 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
fn linked_gradle_paths(
|
||||
root: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
paths: &[String],
|
||||
link: bool,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let existing = settings
|
||||
.linked_projects
|
||||
.clone()
|
||||
.unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect());
|
||||
let mut linked = Vec::new();
|
||||
for path in existing {
|
||||
let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path))
|
||||
.unwrap_or_else(|_| vec![path]);
|
||||
for path in normalized {
|
||||
if !linked.contains(&path) {
|
||||
linked.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
if link {
|
||||
let directories: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let path = root.join(path);
|
||||
let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| {
|
||||
ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name)
|
||||
});
|
||||
if is_script && path.is_file() {
|
||||
path.parent().unwrap_or(root).to_string_lossy().into_owned()
|
||||
} else {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for path in ide_build::gradle::normalize_linked_projects(root, &directories)? {
|
||||
if !linked.contains(&path) {
|
||||
linked.push(path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let unlinked: Vec<String> = paths.iter().map(|p| ide_build::gradle::linked_project_key(root, p)).collect();
|
||||
linked.retain(|path| !paths.contains(path) && !unlinked.contains(path));
|
||||
}
|
||||
Ok(linked)
|
||||
}
|
||||
|
||||
async fn set_gradle_projects(host: &Host, window: &str, paths: Vec<String>, link: bool) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() {
|
||||
return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds"));
|
||||
}
|
||||
let linked =
|
||||
linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?;
|
||||
let key = "languages.java.gradle.linkedProjects";
|
||||
let settings =
|
||||
project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?;
|
||||
project.refresh_index_scope();
|
||||
project.lsp.reconfigure_soon();
|
||||
project.sink.emit(IdeEvent::SettingsChanged { key: key.into() });
|
||||
project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
Ok::<BuildModel, IdeError>(load(&project.root, &project.build_cache_dir(), &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Links independent builds without changing their Gradle scripts.
|
||||
pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
|
||||
set_gradle_projects(host, window, paths, true).await
|
||||
}
|
||||
|
||||
/// Stops importing the selected independent builds; files and saved run configurations stay intact.
|
||||
pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec<String>) -> IdeResult<BuildModel> {
|
||||
set_gradle_projects(host, window, paths, false).await
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn build_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -104,6 +186,7 @@ pub async fn build_sync(
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
build_dir: Option<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
@@ -116,7 +199,14 @@ pub async fn build_sync(
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if profiles_changed {
|
||||
// The modules of the chosen profiles are read from the POMs with them from now on (the
|
||||
// project tree's module folders too), whether or not Maven itself can run.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let completion_token = token.clone();
|
||||
let original_links = settings.languages.java.gradle.linked_projects.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -126,7 +216,7 @@ pub async fn build_sync(
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
BuildTool::Gradle => BTreeMap::new(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -135,14 +225,31 @@ pub async fn build_sync(
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => {
|
||||
if let Some(dir) = &build_dir
|
||||
&& !current.gradle.iter().any(|g| &g.dir == dir)
|
||||
{
|
||||
return Err(IdeError::not_found("Gradle project is no longer linked"));
|
||||
}
|
||||
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options =
|
||||
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
|
||||
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
@@ -150,6 +257,18 @@ pub async fn build_sync(
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let model = result??;
|
||||
if completion_token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
|
||||
{
|
||||
// A late sync must not re-import a build that was unlinked while it ran.
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
}
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
@@ -217,6 +336,58 @@ fn declaring_pom(
|
||||
)))
|
||||
}
|
||||
|
||||
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
|
||||
/// build has downloaded it (`ide_sdk::libraries::published_version_catalog`).
|
||||
pub async fn gradle_published_catalog(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
coordinates: String,
|
||||
) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::libraries::published_version_catalog(&project.root, &project.settings.effective(), &coordinates)
|
||||
.map(Into::into)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The resolved dependency tree of the Gradle project `gradle_path` (`:`, `:app`) for
|
||||
/// `configuration`, from Gradle's `dependencies` report; cancellable with `cancel_request(request)`.
|
||||
pub async fn gradle_dependencies(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
gradle_path: String,
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
build_dir: Option<String>,
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let model = load(&root, &cache, &settings);
|
||||
let gradle = model
|
||||
.gradle
|
||||
.iter()
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
env: gradle.env.clone(),
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
|
||||
pub async fn maven_dependency_declaration(
|
||||
host: &Host,
|
||||
@@ -296,7 +467,7 @@ pub async fn build_run(
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
|
||||
let plan = compile::plan(member, &model, kind, file.as_deref())?;
|
||||
let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?;
|
||||
match &mut combined {
|
||||
Some(combined) => combined.steps.extend(plan.steps),
|
||||
None => combined = Some(plan),
|
||||
@@ -306,7 +477,13 @@ pub async fn build_run(
|
||||
plan.title = workspace.name;
|
||||
plan
|
||||
} else {
|
||||
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
|
||||
compile::plan(
|
||||
&root,
|
||||
&with_profiles(load(&root, &cache, &settings), &cache),
|
||||
kind,
|
||||
file.as_deref(),
|
||||
settings.cmake.profiles.as_deref(),
|
||||
)?
|
||||
};
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
@@ -325,6 +502,11 @@ pub async fn build_run(
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
|
||||
d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add(
|
||||
"gradle_unlink_projects",
|
||||
|c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await },
|
||||
);
|
||||
d.add("build_sync", |c| async move {
|
||||
build_sync(
|
||||
&c.host,
|
||||
@@ -333,10 +515,26 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.arg("buildDir")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("gradle_published_catalog", |c| async move {
|
||||
gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await
|
||||
});
|
||||
d.add("gradle_dependencies", |c| async move {
|
||||
gradle_dependencies(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("gradlePath")?,
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
c.arg("buildDir")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("maven_dependency_declaration", |c| async move {
|
||||
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
|
||||
});
|
||||
@@ -361,6 +559,48 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod gradle_settings_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::GradleSettings::default();
|
||||
let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap();
|
||||
assert_eq!(linked, ["a", "b"]);
|
||||
settings.linked_projects = Some(linked);
|
||||
assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]);
|
||||
assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err());
|
||||
settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]);
|
||||
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap());
|
||||
assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]);
|
||||
settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap());
|
||||
assert!(ide_build::gradle::load_linked(&root, &settings).is_empty());
|
||||
assert!(root.join("a/settings.gradle").is_file());
|
||||
assert!(root.join("b/settings.gradle").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_resolve_each_linked_builds_own_wrapper() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]);
|
||||
let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" };
|
||||
for dir in ["a", "b"] {
|
||||
std::fs::create_dir(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join(dir).join(executable), "").unwrap();
|
||||
}
|
||||
let model = load(&root, &root.join("cache"), &settings);
|
||||
assert_eq!(model.gradle.len(), 2);
|
||||
for g in model.gradle {
|
||||
assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_for_sync_tasks_and_compile_uses_gradle_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -369,13 +609,13 @@ mod gradle_settings_tests {
|
||||
let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
let default = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
assert_eq!(default.command, wrapper.to_string_lossy());
|
||||
settings.languages.java.gradle.jvm = "jdk21".into();
|
||||
settings.languages.java.gradle.user_home = "user-home".into();
|
||||
settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local;
|
||||
settings.languages.java.gradle.home = "gradle-install".into();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
|
||||
let model = load(root, &root.join("cache"), &settings).gradle.remove(0);
|
||||
assert_eq!(
|
||||
model.command,
|
||||
root.join("gradle-install")
|
||||
|
||||
@@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset,
|
||||
/// with paths mapped into this project.
|
||||
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
|
||||
@@ -123,13 +123,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
statements(&c.host, &c.window, &id, &text, from, to)
|
||||
});
|
||||
d.add("db_execute", |c| async move {
|
||||
let (request, id, statements, max_rows) = (
|
||||
let (request, id, statements, skip, max_rows) = (
|
||||
c.arg::<RequestId>("request")?,
|
||||
c.arg::<String>("id")?,
|
||||
c.arg::<Vec<SqlStatement>>("statements")?,
|
||||
c.arg::<Option<u32>>("skip")?.unwrap_or(0),
|
||||
c.arg::<u32>("maxRows")?,
|
||||
);
|
||||
execute(&c.host, &c.window, request, &id, statements, max_rows).await
|
||||
execute(&c.host, &c.window, request, &id, statements, skip, max_rows).await
|
||||
});
|
||||
d.add("db_table_page", |c| async move {
|
||||
let (request, id, schema, table) = (
|
||||
@@ -216,8 +217,7 @@ pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
|
||||
/// manual mode rolls back what was not committed).
|
||||
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.set_manual_commit(id, manual).await;
|
||||
Ok(())
|
||||
db.set_manual_commit(id, manual).await
|
||||
}
|
||||
|
||||
/// Commits or rolls back the open transaction of a manual-commit data source.
|
||||
@@ -326,20 +326,22 @@ pub fn statements(host: &Host, window: &str, id: &str, text: &str, from: u32, to
|
||||
}
|
||||
|
||||
/// Runs `statements` in order on data source `id`, stopping at the first
|
||||
/// error. Cancel with `cancel_request(request)`.
|
||||
/// error. Each result reads past its first `skip` rows (a later page) and keeps
|
||||
/// `max_rows`. Cancel with `cancel_request(request)`.
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
id: &str,
|
||||
statements: Vec<SqlStatement>,
|
||||
skip: u32,
|
||||
max_rows: u32,
|
||||
) -> IdeResult<Vec<StatementOutcome>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let mut out = Vec::new();
|
||||
for statement in statements {
|
||||
match db.execute(id, &statement.text, max_rows.max(1) as usize, &cancel).await {
|
||||
match db.execute_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
|
||||
Ok(result) => out.push(StatementOutcome { statement, result: Some(result), error: None }),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => {
|
||||
host.end_request(request);
|
||||
|
||||
@@ -162,6 +162,19 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
/// Types `text` into the session's program (a `runInTerminal` program).
|
||||
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
|
||||
host.debugger.send_input(session, &text)
|
||||
}
|
||||
|
||||
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_reload(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.hot_restart(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_step(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
@@ -188,6 +201,19 @@ pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) ->
|
||||
host.debugger.stop(session).await
|
||||
}
|
||||
|
||||
/// The adapter's completions for an expression being typed in the frame `frame_id`.
|
||||
pub async fn debug_completions(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<ide_dap::DebugCompletion>> {
|
||||
host.debugger.completions(session, frame_id, text, line, column).await
|
||||
}
|
||||
|
||||
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
|
||||
pub async fn debug_evaluate_in(
|
||||
host: &Host,
|
||||
@@ -360,6 +386,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"debug_pause",
|
||||
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add("debug_send_input", |c| async move {
|
||||
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
|
||||
});
|
||||
d.add("debug_step", |c| async move {
|
||||
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
|
||||
});
|
||||
@@ -376,6 +405,18 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_completions", |c| async move {
|
||||
debug_completions(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("text")?,
|
||||
c.arg("line")?,
|
||||
c.arg("column")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_evaluate_in", |c| async move {
|
||||
debug_evaluate_in(
|
||||
&c.host,
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 1557 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user