mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
166
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
961344c0f9 | ||
|
|
f76c297fe9 | ||
|
|
c75a630ede | ||
|
|
266e4eeb03 | ||
|
|
c1aa092d75 | ||
|
|
b370bb0564 | ||
|
|
c8859b4d3f | ||
|
|
ff187a10f6 | ||
|
|
d984695e79 | ||
|
|
b7d886ea34 | ||
|
|
09c0af504e | ||
|
|
dadda6cf59 | ||
|
|
b1d8d61cb1 | ||
|
|
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 |
No files matched your search
@@ -9,7 +9,8 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
@@ -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.
|
||||
@@ -19,8 +22,50 @@ env:
|
||||
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:
|
||||
@@ -62,6 +107,9 @@ 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@v5
|
||||
@@ -77,6 +125,9 @@ jobs:
|
||||
# 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
|
||||
@@ -126,6 +177,9 @@ jobs:
|
||||
|
||||
deny:
|
||||
name: Cargo deny
|
||||
needs: changes
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -135,6 +189,9 @@ jobs:
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -152,6 +209,9 @@ 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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: CNB mirror
|
||||
|
||||
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
|
||||
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
|
||||
# an existing tag again. Skipped without the CNB_TOKEN secret.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Published release to mirror (e.g. v0.1.38)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cnb-mirror-${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Check token
|
||||
id: token
|
||||
run: |
|
||||
if [ -z "$CNB_TOKEN" ]; then
|
||||
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
with: { sparse-checkout: scripts }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
- name: Download release
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
run: |
|
||||
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
|
||||
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
|
||||
du -sh assets
|
||||
- name: Upload to CNB
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
run: node scripts/cnb-mirror.ts "$TAG" assets
|
||||
@@ -11,10 +11,7 @@ name: Language smoke
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
paths: &paths
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
@@ -27,6 +24,10 @@ on:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -37,6 +37,16 @@ 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
|
||||
@@ -345,3 +355,10 @@ jobs:
|
||||
await github.rest.repos.updateRelease({
|
||||
...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false,
|
||||
});
|
||||
|
||||
# The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is.
|
||||
cnb-mirror:
|
||||
needs: publish
|
||||
uses: ./.github/workflows/cnb-mirror.yml
|
||||
with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" }
|
||||
secrets: inherit
|
||||
@@ -11,6 +11,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -51,7 +51,8 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
|
||||
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 (for UI) before/after screenshots.
|
||||
**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
|
||||
@@ -78,6 +79,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`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
|
||||
|
||||
|
||||
+3
-2
@@ -25,8 +25,9 @@ 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.
|
||||
|
||||
|
||||
Generated
+167
-44
@@ -1552,6 +1552,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1572,6 +1582,20 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1581,7 +1605,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1594,10 +1618,21 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -4071,7 +4106,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4094,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4107,9 +4142,21 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.36"
|
||||
name = "ide-android"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4123,7 +4170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4132,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4149,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4163,13 +4210,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"portable-pty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4181,7 +4230,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4189,6 +4238,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracle",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4207,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4220,7 +4270,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4240,7 +4290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4252,7 +4302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4265,7 +4315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"criterion",
|
||||
@@ -4326,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4344,7 +4394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4366,7 +4416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4383,7 +4433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4439,7 +4489,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4465,7 +4515,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4488,7 +4538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"ide-core",
|
||||
@@ -4504,11 +4554,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4521,7 +4572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4530,6 +4581,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -4538,7 +4590,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4561,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4580,7 +4632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4600,7 +4652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4613,7 +4665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4635,7 +4687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4650,7 +4702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4663,7 +4715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4676,7 +4728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4697,7 +4749,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4718,7 +4770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4740,7 +4792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4756,7 +4808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5961,7 +6013,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6272,6 +6323,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6297,7 +6357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6307,6 +6367,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
@@ -6342,7 +6403,6 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
@@ -6361,10 +6421,12 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6380,6 +6442,32 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -8680,6 +8768,12 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8716,6 +8810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -8938,7 +9033,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
@@ -9964,9 +10059,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-haskell"
|
||||
version = "0.23.1"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
|
||||
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
@@ -10305,6 +10400,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
@@ -10825,6 +10926,28 @@ dependencies = [
|
||||
"wasmtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "258.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b912cfe5696dfaae736e344fc82082dbd63609abbaeca3926466c0b10d9fecc"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.258.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3069154948f5737a8bcd5c55dcdf2c2b59ca776057732e49821167bde268ec6e"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -103,6 +103,7 @@ 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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
<a href="#community">Community</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -57,8 +57,18 @@ 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>
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/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 [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -147,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)
|
||||
@@ -162,16 +164,13 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
}
|
||||
}
|
||||
|
||||
/// `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).
|
||||
@@ -192,7 +191,14 @@ pub fn plan(
|
||||
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())
|
||||
@@ -202,7 +208,7 @@ pub fn plan(
|
||||
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, CMake) found")
|
||||
@@ -270,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);
|
||||
@@ -296,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> {
|
||||
@@ -811,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
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -126,6 +126,7 @@ fn project_path(text: &str) -> String {
|
||||
/// `configuration` and returns its tree.
|
||||
pub fn gradle_dependencies(
|
||||
root: &Path,
|
||||
model: &gradle::GradleModel,
|
||||
options: &SyncOptions,
|
||||
project: &str,
|
||||
configuration: &str,
|
||||
@@ -137,7 +138,6 @@ pub fn gradle_dependencies(
|
||||
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
|
||||
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
|
||||
}
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this 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()];
|
||||
@@ -226,8 +226,12 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = SyncOptions::default();
|
||||
let cancel = CancellationToken::new();
|
||||
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
|
||||
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
|
||||
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)]
|
||||
@@ -246,8 +250,15 @@ runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree =
|
||||
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
|
||||
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(),
|
||||
|
||||
+91
-16
@@ -20,7 +20,9 @@ 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;
|
||||
@@ -38,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.
|
||||
@@ -53,31 +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 maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
|
||||
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`.
|
||||
@@ -121,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)| {
|
||||
@@ -147,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();
|
||||
@@ -158,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")));
|
||||
}
|
||||
@@ -205,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()
|
||||
|
||||
@@ -472,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> {
|
||||
@@ -1065,10 +1100,9 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
load_with_repo(root, default_local_repo())
|
||||
}
|
||||
|
||||
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not.
|
||||
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
|
||||
load_with(root, default_local_repo(), profiles)
|
||||
/// [`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 {
|
||||
@@ -1090,11 +1124,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1216,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 {
|
||||
@@ -1495,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();
|
||||
@@ -1733,6 +1840,49 @@ 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();
|
||||
|
||||
@@ -283,7 +283,12 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load_with_profiles(root, &options.profiles);
|
||||
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 {
|
||||
@@ -373,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![
|
||||
@@ -389,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);
|
||||
@@ -407,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() {
|
||||
@@ -601,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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,8 @@ pub struct GeneralSettings {
|
||||
pub power_save_mode: bool,
|
||||
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -282,6 +284,7 @@ impl Default for GeneralSettings {
|
||||
date_format: DateFormatSettings::default(),
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,7 +604,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,
|
||||
@@ -612,11 +615,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")]
|
||||
@@ -630,6 +656,7 @@ impl LanguagesSettings {
|
||||
match tool {
|
||||
ExternalFormatter::Prettier => &self.prettier,
|
||||
ExternalFormatter::Oxfmt => &self.oxfmt,
|
||||
ExternalFormatter::Ruff => &self.ruff,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,6 +667,7 @@ impl LanguagesSettings {
|
||||
pub enum ExternalFormatter {
|
||||
Prettier,
|
||||
Oxfmt,
|
||||
Ruff,
|
||||
}
|
||||
|
||||
impl ExternalFormatter {
|
||||
@@ -648,16 +676,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,
|
||||
@@ -668,6 +699,7 @@ impl Default for ExternalFormatterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
configuration_directory: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
@@ -722,6 +754,10 @@ pub struct PhpSettings {
|
||||
/// 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)]
|
||||
@@ -828,6 +864,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,
|
||||
@@ -864,6 +902,8 @@ 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`.
|
||||
@@ -1124,6 +1164,16 @@ mod maven_linkage_tests {
|
||||
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();
|
||||
@@ -1136,6 +1186,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());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
@@ -16,6 +17,7 @@ specta.workspace = true
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
parking_lot.workspace = true
|
||||
memchr = "2"
|
||||
portable-pty.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -64,21 +64,27 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"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",
|
||||
@@ -90,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`)")
|
||||
@@ -190,10 +196,18 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
|
||||
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
|
||||
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
|
||||
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
|
||||
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
|
||||
(!own).then(ide_core::tool_path::search_path)
|
||||
}
|
||||
|
||||
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
|
||||
fn search_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
|
||||
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
|
||||
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
|
||||
{
|
||||
@@ -458,6 +472,9 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
@@ -586,6 +603,21 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adapters_run_on_the_tool_search_path() {
|
||||
let search = ide_core::tool_path::search_path();
|
||||
assert_eq!(adapter_path(&[]), Some(search.clone()));
|
||||
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
|
||||
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
|
||||
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
|
||||
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
@@ -662,7 +694,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]
|
||||
|
||||
+103
-23
@@ -19,6 +19,7 @@ mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
use parking_lot::Mutex;
|
||||
@@ -302,6 +303,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))
|
||||
}
|
||||
@@ -493,6 +511,18 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
/// States of a file's line breakpoints that no session has been sent.
|
||||
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
|
||||
breakpoints
|
||||
.iter()
|
||||
.map(|b| BreakpointState {
|
||||
line: b.line,
|
||||
verified: false,
|
||||
message: (!b.enabled).then(|| "Disabled".to_owned()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// States for breakpoints no session has verified yet.
|
||||
fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
|
||||
enabled
|
||||
@@ -570,6 +600,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()
|
||||
@@ -601,6 +635,15 @@ impl DebugManager {
|
||||
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 {
|
||||
@@ -847,12 +890,20 @@ impl DebugManager {
|
||||
let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await;
|
||||
}
|
||||
|
||||
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
|
||||
self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect();
|
||||
// A fresh adapter has no breakpoints, so a file with none enabled needs no request; the
|
||||
// others go out together, as VS Code sends them, instead of one round trip per file.
|
||||
let (files, idle): (Vec<_>, Vec<_>) = self
|
||||
.breakpoints
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(p, b)| (p.clone(), b.clone()))
|
||||
.partition(|(_, b)| b.iter().any(|b| b.enabled));
|
||||
let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await;
|
||||
// Breakpoint states follow the owner's first session.
|
||||
let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id);
|
||||
for (path, bps) in files {
|
||||
match s.send_breakpoints(&path, &bps, None).await {
|
||||
let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps))));
|
||||
for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) {
|
||||
match result {
|
||||
Ok(states) => {
|
||||
if first {
|
||||
self.states.lock().insert(path.clone(), states);
|
||||
@@ -960,16 +1011,7 @@ impl DebugManager {
|
||||
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
|
||||
}
|
||||
}
|
||||
let states = result.unwrap_or_else(|| {
|
||||
breakpoints
|
||||
.iter()
|
||||
.map(|b| BreakpointState {
|
||||
line: b.line,
|
||||
verified: false,
|
||||
message: (!b.enabled).then(|| "Disabled".to_owned()),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let states = result.unwrap_or_else(|| unsent(&breakpoints));
|
||||
self.states.lock().insert(path, states.clone());
|
||||
Ok(states)
|
||||
}
|
||||
@@ -1236,6 +1278,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",
|
||||
@@ -1274,13 +1339,26 @@ impl DebugManager {
|
||||
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
|
||||
/// stop, when the stored ones are restored. `except` is left to the caller.
|
||||
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
|
||||
let files: Vec<PathBuf> = self.breakpoints.lock().keys().cloned().collect();
|
||||
for path in files {
|
||||
if Some(path.as_path()) == except {
|
||||
continue;
|
||||
// While paused the adapter holds the stored lists: only files with something enabled change.
|
||||
let files: Vec<PathBuf> = self
|
||||
.breakpoints
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
|
||||
let mut failed = None;
|
||||
for (path, result) in files.into_iter().zip(sent) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
session.pending_restore.lock().files.insert(path);
|
||||
}
|
||||
Err(e) => failed = failed.or(Some(e)),
|
||||
}
|
||||
session.send_breakpoints(&path, &[], None).await?;
|
||||
session.pending_restore.lock().files.insert(path);
|
||||
}
|
||||
if let Some(e) = failed {
|
||||
return Err(e.into());
|
||||
}
|
||||
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
|
||||
session.send_function_breakpoints(&[]).await?;
|
||||
@@ -1296,9 +1374,11 @@ impl DebugManager {
|
||||
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
|
||||
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
|
||||
let pending = std::mem::take(&mut *session.pending_restore.lock());
|
||||
for path in pending.files {
|
||||
let bps = self.stored_breakpoints(&path);
|
||||
if let Err(e) = session.send_breakpoints(&path, &bps, None).await {
|
||||
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
|
||||
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
|
||||
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
|
||||
for ((path, _), result) in files.iter().zip(sent) {
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -266,6 +269,9 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,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)
|
||||
@@ -461,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 {
|
||||
@@ -480,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();
|
||||
|
||||
@@ -17,6 +17,8 @@ struct Script {
|
||||
start_debugging: bool,
|
||||
ignore_configuration_done: bool,
|
||||
source_path: Option<String>,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
}
|
||||
|
||||
struct FakeLauncher {
|
||||
@@ -49,6 +51,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut r = BufReader::new(r);
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
let mut v: Value = $v;
|
||||
@@ -101,14 +104,20 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
"setBreakpoints" => {
|
||||
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
|
||||
.collect();
|
||||
ok!(msg, json!({"breakpoints": bps}));
|
||||
held.push(msg);
|
||||
if held.len() < script.hold_set_breakpoints {
|
||||
continue;
|
||||
}
|
||||
for msg in std::mem::take(&mut held) {
|
||||
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
|
||||
.collect();
|
||||
ok!(msg, json!({"breakpoints": bps}));
|
||||
}
|
||||
}
|
||||
"setExceptionBreakpoints" => ok!(msg, json!({})),
|
||||
"setFunctionBreakpoints" | "setDataBreakpoints" => {
|
||||
@@ -468,6 +477,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();
|
||||
@@ -887,6 +930,7 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
|
||||
let b = PathBuf::from("/p/b.py");
|
||||
h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap();
|
||||
h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap();
|
||||
h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
|
||||
h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap();
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
@@ -915,6 +959,24 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
|
||||
h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap();
|
||||
assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}]));
|
||||
assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([]));
|
||||
assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() {
|
||||
let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() });
|
||||
h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap();
|
||||
h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap();
|
||||
let off = PathBuf::from("/p/off.py");
|
||||
h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
|
||||
// Waiting for each answer before the next request would stall until the request timeout.
|
||||
tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap();
|
||||
let paths: Vec<Value> = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect();
|
||||
assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]);
|
||||
assert_eq!(
|
||||
h.manager.breakpoint_states(&off),
|
||||
[BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1269,3 +1331,19 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
|
||||
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());
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
|
||||
@@ -150,6 +150,7 @@ impl ClickhousePool {
|
||||
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())
|
||||
@@ -177,6 +178,17 @@ impl ClickhousePool {
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -188,7 +200,7 @@ impl ClickhousePool {
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
|
||||
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)?;
|
||||
@@ -216,7 +228,7 @@ impl ClickhousePool {
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
let mut reader = RowReader::new(skip, max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
@@ -309,11 +321,13 @@ impl ClickhousePool {
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// Decodes a streamed [`FORMAT`] body. Keeps the first `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
|
||||
/// 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>>,
|
||||
@@ -324,8 +338,17 @@ struct RowReader {
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
fn new(max_rows: usize) -> Self {
|
||||
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
|
||||
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<()> {
|
||||
@@ -356,9 +379,15 @@ impl RowReader {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
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)));
|
||||
@@ -547,7 +576,11 @@ mod tests {
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
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)?;
|
||||
}
|
||||
@@ -586,6 +619,18 @@ mod tests {
|
||||
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");
|
||||
|
||||
+110
-15
@@ -107,6 +107,7 @@ pub enum Pool {
|
||||
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.
|
||||
@@ -116,7 +117,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -262,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 {
|
||||
@@ -337,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();
|
||||
@@ -349,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 {
|
||||
@@ -357,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;
|
||||
@@ -378,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.
|
||||
@@ -386,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
|
||||
@@ -399,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;
|
||||
@@ -489,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 {
|
||||
@@ -513,6 +540,9 @@ impl Setup {
|
||||
(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())),
|
||||
}
|
||||
}
|
||||
@@ -535,6 +565,15 @@ impl Pool {
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +584,9 @@ impl Pool {
|
||||
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.
|
||||
@@ -707,12 +749,13 @@ impl Pool {
|
||||
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),
|
||||
}
|
||||
@@ -721,6 +764,7 @@ 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()),
|
||||
}
|
||||
@@ -731,10 +775,15 @@ impl Pool {
|
||||
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)),
|
||||
};
|
||||
@@ -744,11 +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(sql, max_rows, cancel).await;
|
||||
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());
|
||||
@@ -785,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;
|
||||
@@ -812,6 +882,7 @@ pub enum Held {
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -827,6 +898,7 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -835,13 +907,20 @@ impl Held {
|
||||
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(_) | Held::Clickhouse(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -863,6 +942,7 @@ impl Held {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,9 +954,22 @@ impl Held {
|
||||
sql: &str,
|
||||
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(sql, max_rows, cancel).await;
|
||||
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.
|
||||
@@ -887,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() {
|
||||
@@ -916,10 +1009,11 @@ 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;
|
||||
@@ -941,6 +1035,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Oracle => "", // opens implicitly with autocommit off
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
@@ -303,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);
|
||||
}
|
||||
}
|
||||
@@ -614,6 +615,7 @@ impl DbManager {
|
||||
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 result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
@@ -905,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() {
|
||||
@@ -1009,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),
|
||||
@@ -1044,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)
|
||||
}
|
||||
|
||||
@@ -1052,13 +1117,14 @@ 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"));
|
||||
@@ -1070,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;
|
||||
|
||||
@@ -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())]));
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,28 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
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::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");
|
||||
@@ -148,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| {
|
||||
@@ -202,6 +251,49 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -20,6 +20,8 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -30,6 +32,7 @@ impl DbDriver {
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,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 `?`.
|
||||
@@ -261,6 +266,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
"oracle" | "oracledb" => Some(DbDriver::Oracle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -273,6 +279,12 @@ pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
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();
|
||||
@@ -540,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());
|
||||
}
|
||||
|
||||
|
||||
+197
-7
@@ -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,
|
||||
@@ -29,6 +30,8 @@ enum State {
|
||||
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);
|
||||
@@ -46,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;
|
||||
@@ -69,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;
|
||||
@@ -86,15 +110,28 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
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)..];
|
||||
@@ -114,6 +151,34 @@ 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,
|
||||
_ => {}
|
||||
},
|
||||
@@ -164,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.
|
||||
@@ -277,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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
+182
-5
@@ -46,6 +46,23 @@ 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",
|
||||
@@ -59,6 +76,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
}
|
||||
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') \
|
||||
@@ -89,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
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 \
|
||||
@@ -148,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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +378,9 @@ 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +392,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,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));
|
||||
}
|
||||
@@ -421,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"),
|
||||
});
|
||||
}
|
||||
@@ -509,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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,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
|
||||
@@ -606,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,
|
||||
})
|
||||
}
|
||||
@@ -792,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,9 @@ impl Capability {
|
||||
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 => "Read and write the clipboard from its web views".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"),
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.0.0";
|
||||
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.
|
||||
|
||||
@@ -98,7 +98,15 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
|
||||
*p += 1;
|
||||
Ok(())
|
||||
}
|
||||
Some(Tok::Id(_)) => {
|
||||
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;
|
||||
@@ -133,4 +141,23 @@ mod tests {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod phpstan;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
@@ -86,6 +87,8 @@ pub struct Host {
|
||||
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.
|
||||
@@ -135,6 +138,7 @@ impl Host {
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -219,12 +223,19 @@ impl Host {
|
||||
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();
|
||||
@@ -238,6 +249,7 @@ impl Host {
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
Some(project.lsp.clone()),
|
||||
Some((self.debugger.clone(), window.to_owned())),
|
||||
Some(project),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -250,6 +262,7 @@ impl Host {
|
||||
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();
|
||||
@@ -259,6 +272,10 @@ impl Host {
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
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;
|
||||
@@ -273,6 +290,9 @@ impl Host {
|
||||
debug.stop_key(&key).await;
|
||||
}
|
||||
});
|
||||
if let Some(retired) = retired {
|
||||
let _ = retired.join();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,6 +332,7 @@ impl Host {
|
||||
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()) {
|
||||
@@ -346,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();
|
||||
@@ -401,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>> {
|
||||
@@ -58,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
|
||||
@@ -72,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);
|
||||
@@ -263,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)?;
|
||||
@@ -122,6 +205,8 @@ pub async fn build_sync(
|
||||
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 {
|
||||
@@ -131,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,
|
||||
@@ -140,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))
|
||||
})
|
||||
@@ -155,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();
|
||||
@@ -247,20 +361,26 @@ pub async fn gradle_dependencies(
|
||||
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 gradle = load(&root, &cache, &settings).gradle;
|
||||
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.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
|
||||
env: gradle.env.clone(),
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -382,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,
|
||||
@@ -390,6 +515,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.arg("buildDir")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
@@ -405,6 +531,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
c.arg("buildDir")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
@@ -432,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();
|
||||
@@ -440,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) = (
|
||||
@@ -325,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,
|
||||
@@ -373,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
|
||||
});
|
||||
|
||||
@@ -242,6 +242,29 @@ mod tests {
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
|
||||
}
|
||||
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
|
||||
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
|
||||
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
|
||||
#[test]
|
||||
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
|
||||
let source = root.join("src/main/kotlin/Main.kt");
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
|
||||
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -204,6 +204,17 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
let (path, version) = project.documents.identity(doc)?;
|
||||
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}) {
|
||||
return Ok(found.targets);
|
||||
}
|
||||
}
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
@@ -257,6 +268,28 @@ pub async fn lsp_implementations(
|
||||
result
|
||||
}
|
||||
|
||||
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
|
||||
/// search for the rest (Java: the project's, before the dependencies') arrive on
|
||||
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
|
||||
pub async fn lsp_goto_implementation(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
on_partial: super::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp
|
||||
.goto_implementation(doc, pos, cancel, &|locs| {
|
||||
let _ = on_partial.send(locs);
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
@@ -746,6 +779,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add("lsp_goto_implementation", |c| async move {
|
||||
let on_partial = c.sender("onPartial")?;
|
||||
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_resolve_class_file",
|
||||
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
|
||||
|
||||
@@ -196,7 +196,9 @@ pub fn set_settings(
|
||||
}
|
||||
let roots_changed = key == "projectStructure"
|
||||
|| key.starts_with("projectStructure.")
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key);
|
||||
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|
||||
|| "languages.java.maven.linkedProjects".starts_with(key)
|
||||
|| "languages.java.gradle.linkedProjects".starts_with(key);
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
|
||||
@@ -20,7 +20,7 @@ const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSource {
|
||||
pub path: String,
|
||||
pub path: ide_core::IpcPath,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
|
||||
found.push(RouteSource { path: path.to_path_buf().into(), text });
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_default();
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
@@ -116,7 +116,7 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &[]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
|
||||
assert!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
|
||||
assert!(files[0].text.contains("@GetMapping"));
|
||||
}
|
||||
|
||||
@@ -129,6 +129,6 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
assert!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::remote_settings::RunTargetKind;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
use std::time::Duration;
|
||||
@@ -140,12 +141,33 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
// Settings › Run Targets: over SSH, or in a container.
|
||||
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
|
||||
}
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
// this machine's toolchains and `PYTHONPATH` don't apply.
|
||||
let config = container::prepare(host, &project.root, target, config).await?;
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
// A folder on this machine: not in the container.
|
||||
ruby_reporter: None,
|
||||
};
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
project.sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
@@ -198,6 +220,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
ruby_reporter: ruby_reporter_dir(host, &config),
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
@@ -214,6 +237,21 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// The Ruby test reporter's directory, installed on demand, for runs that take it.
|
||||
fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathBuf> {
|
||||
if !ide_run::ruby_reporter::applies(config) {
|
||||
return None;
|
||||
}
|
||||
let dir = host.local_data_dir.join("ruby-reporter");
|
||||
match ide_run::ruby_reporter::install(&dir) {
|
||||
Ok(()) => Some(dir),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot install the Ruby test reporter in {}: {e}", dir.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
let settings = match host.project(window) {
|
||||
@@ -255,4 +293,5 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod container;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Docker and Compose run targets: the configuration becomes a local run of the
|
||||
//! docker CLI (see `ide_remote::docker`).
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::remote_settings::{RunTarget, RunTargetKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_docker::DockerEndpoint;
|
||||
use ide_remote::docker::{
|
||||
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
|
||||
};
|
||||
use ide_run::RunConfig;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `config` rewritten to run in a new container of `target`'s image or service.
|
||||
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
|
||||
if config.kind == "dotnet" || config.kind == "dotnet-test" {
|
||||
return Err(IdeError::invalid(".NET Project requires the local machine"));
|
||||
}
|
||||
let endpoint = endpoint(&host.data_dir, &target)?;
|
||||
let root = root.to_path_buf();
|
||||
host.blocking(move || {
|
||||
if !ide_docker::endpoint_is_local(&endpoint)? {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| {
|
||||
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
|
||||
})?;
|
||||
let container_root = match target.container_root.trim() {
|
||||
"" if target.kind == RunTargetKind::Compose => {
|
||||
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
|
||||
compose_container_root(&json, target.service.trim(), &root)?
|
||||
}
|
||||
"" => default_container_root(&root),
|
||||
folder => folder.to_owned(),
|
||||
};
|
||||
let file_env = env_files(&root, &config)?;
|
||||
let name = container_name(&config.id, &unique());
|
||||
container_config(
|
||||
&config,
|
||||
&ContainerRun {
|
||||
target: &target,
|
||||
root: &root,
|
||||
container_root: &container_root,
|
||||
docker: &docker.to_string_lossy(),
|
||||
endpoint_env: &ide_docker::run_env(&endpoint),
|
||||
file_env: &file_env,
|
||||
name: &name,
|
||||
user: user(&root, &target),
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
|
||||
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
|
||||
let id = target.docker_connection.trim();
|
||||
if id.is_empty() {
|
||||
return Ok(DockerEndpoint::Default);
|
||||
}
|
||||
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|
||||
|| {
|
||||
IdeError::not_found(format!(
|
||||
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
|
||||
target.name
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The configuration's `.env` files, read here: the container gets their variables.
|
||||
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
let extras = &config.extras;
|
||||
if extras.env_files.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let paths: Vec<PathBuf> = extras
|
||||
.env_files
|
||||
.iter()
|
||||
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
|
||||
.collect();
|
||||
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
|
||||
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
|
||||
}
|
||||
|
||||
/// On Linux the container writes files as the project folder's owner, unless the
|
||||
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
|
||||
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if !target.image_user {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
|
||||
}
|
||||
let _ = (root, target);
|
||||
None
|
||||
}
|
||||
|
||||
/// Distinguishes runs of the same configuration.
|
||||
fn unique() -> String {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
||||
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
@@ -23,8 +23,26 @@ pub struct LocatedTools {
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
let project = host.project(window).ok();
|
||||
let root = project.as_ref().map(|p| p.root.clone());
|
||||
// The interpreter the project uses is a choice even where detection doesn't look (a
|
||||
// virtualenv in a subfolder, one picked by path), so it shows by name and version.
|
||||
let current = match (&project, kind) {
|
||||
(Some(p), SdkKind::Python) => ide_sdk::apply::python_interpreter(&p.root, &p.settings.effective()),
|
||||
_ => None,
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut sdks = ide_sdk::detect(kind, root.as_deref());
|
||||
if let Some(path) = current
|
||||
&& !sdks.iter().any(|s| *s.path == *path)
|
||||
&& let Some(sdk) = ide_sdk::python_interpreter_sdk(&path, root.as_deref())
|
||||
{
|
||||
sdks.push(sdk);
|
||||
}
|
||||
sdks
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
@@ -34,6 +52,14 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let settings = project.settings.effective();
|
||||
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
/// the language server configuration picks it).
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
@@ -70,6 +96,33 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComposerInstallState {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub reason: ide_sdk::php::ComposerInstall,
|
||||
}
|
||||
|
||||
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
|
||||
pub async fn sdk_php_composer_install(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<Option<ComposerInstallState>> {
|
||||
let project = host.project(window)?;
|
||||
if !project.is_trusted() {
|
||||
return Ok(None);
|
||||
}
|
||||
let root = project.root.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::php::composer_install(&path, &root)
|
||||
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -180,10 +233,15 @@ pub async fn sdk_python_pip(
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add(
|
||||
"sdk_php_composer_install",
|
||||
|c| async move { sdk_php_composer_install(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
|
||||
@@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
.collect();
|
||||
// Godot is usually an app bundle / Flatpak outside the search path.
|
||||
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
|
||||
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
|
||||
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
|
||||
Ok(Tools::new(&data)
|
||||
.with_custom_paths(custom)
|
||||
.with_found_paths(found.into_iter().collect())
|
||||
.with_work_dir(work_dir))
|
||||
}
|
||||
|
||||
/// The language definitions plus the servers defined in the window's settings.
|
||||
|
||||
@@ -103,12 +103,20 @@ pub enum RemoteOp {
|
||||
Pull {
|
||||
rebase: bool,
|
||||
},
|
||||
/// Update Project › Reset to the Remote Branch.
|
||||
ResetToUpstream,
|
||||
Push {
|
||||
force_with_lease: bool,
|
||||
set_upstream: bool,
|
||||
/// Push dialog › Push tags.
|
||||
#[serde(default)]
|
||||
tags: Option<PushTags>,
|
||||
/// Plain `--force` (Force push anyway).
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
/// Push dialog › Run Git hooks unchecked (`--no-verify`).
|
||||
#[serde(default)]
|
||||
no_verify: bool,
|
||||
},
|
||||
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
|
||||
DeleteBranch {
|
||||
@@ -125,6 +133,23 @@ pub enum RemoteOp {
|
||||
target: Option<String>,
|
||||
#[serde(default)]
|
||||
remote: Option<String>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
/// Record the target as upstream (Push dialog › Set upstream).
|
||||
#[serde(default)]
|
||||
set_upstream: bool,
|
||||
#[serde(default)]
|
||||
no_verify: bool,
|
||||
},
|
||||
/// Push Tag › Push to `remote`.
|
||||
PushTag {
|
||||
tag: String,
|
||||
remote: String,
|
||||
},
|
||||
/// Deletes tag `tag` on `remote` (Delete on Remote).
|
||||
DeleteRemoteTag {
|
||||
tag: String,
|
||||
remote: String,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
@@ -155,11 +180,15 @@ pub struct GitExecutableInfo {
|
||||
pub enum GitOperation {
|
||||
/// Continue the merge / rebase / cherry-pick / revert in progress.
|
||||
Continue,
|
||||
/// Stage and Retry: stage the changes of tracked files, then continue.
|
||||
StageAndContinue,
|
||||
Abort,
|
||||
Skip,
|
||||
/// Commits oldest first.
|
||||
/// Commits oldest first; `record_origin` adds "(cherry picked from commit …)".
|
||||
CherryPick {
|
||||
commits: Vec<String>,
|
||||
#[serde(default, rename = "recordOrigin")]
|
||||
record_origin: bool,
|
||||
},
|
||||
/// Commits newest first.
|
||||
Revert {
|
||||
@@ -405,28 +434,6 @@ pub async fn vcs_log_details_batch(
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_log_index(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<LogIndexProgress> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let cancel =
|
||||
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
|
||||
})
|
||||
})
|
||||
.await;
|
||||
if let Some(request) = request {
|
||||
host.end_request(request);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_revision_children(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -492,7 +499,7 @@ pub async fn vcs_add_to_ignore(
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -509,7 +516,7 @@ pub async fn vcs_add_to_ignore(
|
||||
watcher.reload_ignore_rules();
|
||||
}
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
|
||||
result.map(|files| files.into_iter().map(Into::into).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -594,6 +601,33 @@ pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, c
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
|
||||
/// Edit Commit Message: rewrites the commit and its descendants without touching the
|
||||
/// working tree.
|
||||
pub async fn vcs_reword(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
message: String,
|
||||
) -> IdeResult<ide_vcs::HeadChange> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.reword(&commit, &message))).await
|
||||
}
|
||||
|
||||
/// Undo of a history rewrite (Edit Commit Message, Drop, Squash).
|
||||
pub async fn vcs_restore_head(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
before: String,
|
||||
after: String,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_head(&before, &after))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_delete_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -606,6 +640,19 @@ pub async fn vcs_delete_branch(
|
||||
}
|
||||
|
||||
/// "Restore" of the branch-deleted notification.
|
||||
/// Deletes a local tag; the result restores it.
|
||||
pub async fn vcs_delete_tag(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<DeletedTag> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_tag(&name))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_restore_tag(host: &Host, window: &str, repo: Option<PathBuf>, deleted: DeletedTag) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_tag(&deleted))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_restore_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -701,26 +748,42 @@ pub async fn vcs_remote(
|
||||
let out = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
|
||||
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. } | RemoteOp::ResetToUpstream)
|
||||
.then_some("Git pull");
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
RemoteOp::Fetch => r.fetch(&cancel, &out),
|
||||
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
|
||||
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
RemoteOp::ResetToUpstream => r.reset_to_upstream(&cancel, &out),
|
||||
RemoteOp::Push { force_with_lease, set_upstream, tags, force, no_verify } => {
|
||||
r.push_with(PushOptions { force_with_lease, force, set_upstream, tags, no_verify }, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
|
||||
RemoteOp::PushBranch {
|
||||
branch,
|
||||
revision,
|
||||
force_with_lease,
|
||||
target,
|
||||
remote,
|
||||
force,
|
||||
set_upstream,
|
||||
no_verify,
|
||||
} => r.push_branch_to(
|
||||
&branch,
|
||||
PushBranchOptions {
|
||||
revision: revision.as_deref(),
|
||||
target: target.as_deref(),
|
||||
remote: remote.as_deref(),
|
||||
force_with_lease,
|
||||
force,
|
||||
set_upstream,
|
||||
no_verify,
|
||||
},
|
||||
&cancel,
|
||||
&out,
|
||||
),
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushTag { tag, remote } => r.push_tag(&tag, &remote, &cancel, &out),
|
||||
RemoteOp::DeleteRemoteTag { tag, remote } => r.delete_remote_tag(&tag, &remote, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
}
|
||||
@@ -856,9 +919,12 @@ pub async fn vcs_operation(
|
||||
};
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
GitOperation::Continue => r.continue_operation(&cancel, &out),
|
||||
GitOperation::StageAndContinue => r.stage_and_continue(&cancel, &out),
|
||||
GitOperation::Abort => r.abort_operation(&cancel, &out),
|
||||
GitOperation::Skip => r.skip_operation(&cancel, &out),
|
||||
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
|
||||
GitOperation::CherryPick { commits, record_origin } => {
|
||||
r.cherry_pick_with(&commits, record_origin, &cancel, &out)
|
||||
}
|
||||
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
|
||||
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
|
||||
GitOperation::InteractiveRebase { base, entries } => {
|
||||
@@ -1029,6 +1095,25 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
|
||||
pub async fn vcs_diff_numstat(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
from: String,
|
||||
to: String,
|
||||
paths: Vec<String>,
|
||||
) -> IdeResult<Vec<FileLineStats>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
|
||||
}
|
||||
|
||||
/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks).
|
||||
pub async fn vcs_has_hook(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<bool> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.has_hook(&name))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
@@ -1222,10 +1307,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_log_details_batch", |c| async move {
|
||||
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_log_index",
|
||||
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
|
||||
);
|
||||
d.add("vcs_revision_children", |c| async move {
|
||||
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
|
||||
});
|
||||
@@ -1260,9 +1341,22 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"vcs_undo_commit",
|
||||
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
|
||||
);
|
||||
d.add("vcs_reword", |c| async move {
|
||||
vcs_reword(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?, c.arg("message")?).await
|
||||
});
|
||||
d.add("vcs_restore_head", |c| async move {
|
||||
vcs_restore_head(&c.host, &c.window, c.arg("repo")?, c.arg("before")?, c.arg("after")?).await
|
||||
});
|
||||
d.add("vcs_delete_branch", |c| async move {
|
||||
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_delete_tag",
|
||||
|c| async move { vcs_delete_tag(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await },
|
||||
);
|
||||
d.add("vcs_restore_tag", |c| async move {
|
||||
vcs_restore_tag(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_restore_branch", |c| async move {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
@@ -1383,6 +1477,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_diff_numstat", |c| async move {
|
||||
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
|
||||
});
|
||||
d.add("vcs_has_hook", |c| async move { vcs_has_hook(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await });
|
||||
d.add("vcs_merge_base", |c| async move {
|
||||
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics.
|
||||
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::output_encoding::decode_tool_output;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
|
||||
use ide_workspace::SettingsStore;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(750);
|
||||
const SOURCE: &str = "phpstan";
|
||||
|
||||
/// Saved files waiting for a check. Each save cancels the wait or the check in progress.
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
generation: u64,
|
||||
cancel: CancellationToken,
|
||||
files: BTreeSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
fn push(&mut self, files: impl IntoIterator<Item = PathBuf>) {
|
||||
self.files.extend(files);
|
||||
self.cancel.cancel();
|
||||
self.generation += 1;
|
||||
self.cancel = CancellationToken::new();
|
||||
}
|
||||
fn current(&self, generation: u64) -> bool {
|
||||
self.generation == generation && !self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Phpstan {
|
||||
root: PathBuf,
|
||||
config_dir: PathBuf,
|
||||
settings: Arc<SettingsStore>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
lifetime: CancellationToken,
|
||||
queue: Mutex<Queue>,
|
||||
changed: Notify,
|
||||
/// Files with PHPStan diagnostics, the only ones a clean result or a removal clears.
|
||||
shown: Mutex<HashSet<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Phpstan {
|
||||
pub(crate) fn new(
|
||||
root: PathBuf,
|
||||
config_dir: PathBuf,
|
||||
settings: Arc<SettingsStore>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
root,
|
||||
config_dir,
|
||||
settings,
|
||||
sink,
|
||||
trusted,
|
||||
lifetime: CancellationToken::new(),
|
||||
queue: Mutex::new(Queue::default()),
|
||||
changed: Notify::new(),
|
||||
shown: Mutex::new(HashSet::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
|
||||
let checker = self.clone();
|
||||
runtime.spawn(async move { checker.run().await });
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
self.lifetime.cancel();
|
||||
self.queue.lock().cancel.cancel();
|
||||
}
|
||||
|
||||
/// Stops the check in progress (trust changed); its files wait for the next one, which drops
|
||||
/// them while the project is in Restricted Mode.
|
||||
pub(crate) fn interrupt(&self) {
|
||||
self.queue.lock().push([]);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
fn files_saved(&self, paths: impl IntoIterator<Item = PathBuf>) {
|
||||
let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable();
|
||||
if self.lifetime.is_cancelled() || files.peek().is_none() {
|
||||
return;
|
||||
}
|
||||
self.queue.lock().push(files);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
/// Deleted files and folders lose their diagnostics.
|
||||
pub(crate) fn files_removed<'a>(&self, removed: impl Iterator<Item = &'a PathBuf>) {
|
||||
let removed: Vec<&PathBuf> = removed.collect();
|
||||
let gone: Vec<PathBuf> =
|
||||
self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect();
|
||||
for path in gone {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(&self, path: PathBuf, items: Vec<Diagnostic>) {
|
||||
let mut shown = self.shown.lock();
|
||||
if items.is_empty() {
|
||||
if !shown.remove(&path) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
shown.insert(path.clone());
|
||||
}
|
||||
// Under the lock, so a removal and a result for one file reach the UI in the order decided here.
|
||||
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items });
|
||||
}
|
||||
|
||||
async fn run(self: Arc<Self>) {
|
||||
// The failure last reported per root: each is reported once, until a run succeeds.
|
||||
let mut failures: BTreeMap<PathBuf, String> = BTreeMap::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.lifetime.cancelled() => return,
|
||||
_ = self.changed.notified() => {},
|
||||
}
|
||||
let (generation, cancel) = {
|
||||
let queue = self.queue.lock();
|
||||
(queue.generation, queue.cancel.clone())
|
||||
};
|
||||
if !debounce(&cancel, &self.lifetime).await {
|
||||
continue;
|
||||
}
|
||||
let files = {
|
||||
let mut queue = self.queue.lock();
|
||||
if !queue.current(generation) {
|
||||
continue;
|
||||
}
|
||||
std::mem::take(&mut queue.files)
|
||||
};
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone());
|
||||
let Ok((checks, unchecked)) =
|
||||
tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for path in unchecked {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
let mut checks = checks.into_iter();
|
||||
while let Some(check) = checks.next() {
|
||||
let started = std::time::Instant::now();
|
||||
let program = check.command.get_program().to_string_lossy().into_owned();
|
||||
let output = analyse(check.command, &cancel).await;
|
||||
let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else {
|
||||
// A newer save: these files are checked again with it.
|
||||
let mut queue = self.queue.lock();
|
||||
queue.files.extend(check.files);
|
||||
queue.files.extend(checks.by_ref().flat_map(|c| c.files));
|
||||
break;
|
||||
};
|
||||
tracing::debug!(
|
||||
"phpstan checked {} file(s) in {} ms",
|
||||
check.files.len(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
// Files deleted meanwhile make the result (or the failure) moot: check the rest again.
|
||||
let (present, missing): (Vec<PathBuf>, Vec<PathBuf>) =
|
||||
check.files.iter().cloned().partition(|f| f.is_file());
|
||||
if !missing.is_empty() {
|
||||
for path in missing {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
self.files_saved(present);
|
||||
continue;
|
||||
}
|
||||
let report = match output {
|
||||
Ok(output) => {
|
||||
let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr));
|
||||
parsed.map_err(|text| {
|
||||
if text.is_empty() {
|
||||
let status = output.status;
|
||||
format!("PHPStan ended with {status} and no report")
|
||||
} else {
|
||||
text
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(error) => Err(format!("Cannot run {program}: {error}")),
|
||||
};
|
||||
let failure = match report {
|
||||
Ok(report) => {
|
||||
let files = check.files.clone();
|
||||
let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for (path, items) in results {
|
||||
// Severe errors (a syntax error) stop the analysis of the other files.
|
||||
if report.complete || !items.is_empty() {
|
||||
self.publish(path, items);
|
||||
}
|
||||
}
|
||||
report.errors.join("\n")
|
||||
}
|
||||
Err(text) => {
|
||||
// Without a report the earlier errors of these files may be stale.
|
||||
for path in check.files {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
text
|
||||
}
|
||||
};
|
||||
if failure.is_empty() {
|
||||
failures.remove(&check.root);
|
||||
} else {
|
||||
self.failure(&check.root, failure, &mut failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap<PathBuf, String>) {
|
||||
if failures.get(root) == Some(&text) {
|
||||
return;
|
||||
}
|
||||
self.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "PHPStan check failed".into(),
|
||||
body: Some(text.clone()),
|
||||
});
|
||||
failures.insert(root.to_path_buf(), text);
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentObserver for Phpstan {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
self.files_saved([path.to_path_buf()]);
|
||||
}
|
||||
}
|
||||
|
||||
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
_ = lifetime.cancelled() => false,
|
||||
_ = cancel.cancelled() => false,
|
||||
_ = tokio::time::sleep(DELAY) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// One `vendor/bin/phpstan` run: the saved files below one PHP project.
|
||||
struct Check {
|
||||
root: PathBuf,
|
||||
files: Vec<PathBuf>,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer
|
||||
/// apply. A file belongs to the innermost linked project (with its own settings) that contains it,
|
||||
/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a
|
||||
/// project without PHPStan are not checked.
|
||||
fn plan(
|
||||
project: &Path,
|
||||
config_dir: &Path,
|
||||
settings: &SettingsStore,
|
||||
files: BTreeSet<PathBuf>,
|
||||
) -> (Vec<Check>, Vec<PathBuf>) {
|
||||
let members =
|
||||
ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default();
|
||||
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
|
||||
let mut unchecked = Vec::new();
|
||||
for file in files {
|
||||
let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count());
|
||||
let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf()));
|
||||
match root {
|
||||
Some(root) if file.is_file() => groups.entry(root).or_default().push(file),
|
||||
_ => unchecked.push(file),
|
||||
}
|
||||
}
|
||||
let mut checks = Vec::new();
|
||||
for (root, files) in groups {
|
||||
let effective = if root == project {
|
||||
settings.effective()
|
||||
} else {
|
||||
SettingsStore::load(config_dir, Some(&root)).effective()
|
||||
};
|
||||
let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs());
|
||||
let (files, skipped): (Vec<PathBuf>, Vec<PathBuf>) = files.into_iter().partition(|f| {
|
||||
f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel))
|
||||
});
|
||||
unchecked.extend(skipped);
|
||||
match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) {
|
||||
Some(command) => checks.push(Check { root, files, command }),
|
||||
None => unchecked.extend(files),
|
||||
}
|
||||
}
|
||||
(checks, unchecked)
|
||||
}
|
||||
|
||||
/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it.
|
||||
async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option<std::io::Result<Output>> {
|
||||
if cancel.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
ide_core::process::own_group(&mut cmd);
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut cmd = tokio::process::Command::from(cmd);
|
||||
cmd.kill_on_drop(true);
|
||||
let child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => return Some(Err(error)),
|
||||
};
|
||||
let pid = child.id();
|
||||
// PHPStan may start workers of its own.
|
||||
let cancelled = async {
|
||||
cancel.cancelled().await;
|
||||
if let Some(pid) = pid {
|
||||
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
output = child.wait_with_output() => Some(output),
|
||||
() = cancelled => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A PHPStan message: 0-based line, text with its tip, identifier.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Message {
|
||||
line: u32,
|
||||
text: String,
|
||||
identifier: Option<String>,
|
||||
}
|
||||
|
||||
/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and
|
||||
/// whether every file was analysed (severe errors such as a syntax error stop the others).
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Report {
|
||||
files: BTreeMap<String, Vec<Message>>,
|
||||
errors: Vec<String>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl Default for Report {
|
||||
fn default() -> Self {
|
||||
Self { files: BTreeMap::new(), errors: Vec::new(), complete: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// PHPStan's `--error-format=json` report, or the text to report when there is none (an
|
||||
/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints,
|
||||
/// may precede the report on stdout.
|
||||
fn parse(stdout: &str, stderr: &str) -> Result<Report, String> {
|
||||
let json = serde_json::from_str::<Value>(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| {
|
||||
stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok()))
|
||||
});
|
||||
let Some(Value::Object(json)) = json else {
|
||||
// Every saved file is excluded by the configuration (`excludePaths`).
|
||||
if stderr.contains("No files found to analyse") {
|
||||
return Ok(Report::default());
|
||||
}
|
||||
return Err(failure_text(stdout, stderr));
|
||||
};
|
||||
let mut report = Report::default();
|
||||
if let Some(Value::Object(files)) = json.get("files") {
|
||||
for (file, result) in files {
|
||||
let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten();
|
||||
report.files.insert(file.clone(), messages.filter_map(message).collect());
|
||||
}
|
||||
}
|
||||
let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten();
|
||||
report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect();
|
||||
report.complete = !stderr.contains("Result is incomplete");
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn message(m: &Value) -> Option<Message> {
|
||||
let mut text = m.get("message")?.as_str()?.to_owned();
|
||||
if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) {
|
||||
text = format!("{text}\n{}", strip_console_tags(tip));
|
||||
}
|
||||
let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1;
|
||||
Some(Message {
|
||||
line: u32::try_from(line).unwrap_or(u32::MAX),
|
||||
text,
|
||||
identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Tips may carry console styling (`<fg=cyan>…</>`).
|
||||
fn strip_console_tags(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find('<') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let tag = rest.find('>').map(|end| &rest[..=end]);
|
||||
match tag {
|
||||
Some(tag)
|
||||
if tag == "</>"
|
||||
|| tag.starts_with("<fg=")
|
||||
|| tag.starts_with("<bg=")
|
||||
|| tag.starts_with("<options=") =>
|
||||
{
|
||||
rest = &rest[tag.len()..];
|
||||
}
|
||||
_ => {
|
||||
out.push('<');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// What PHPStan printed instead of a report, without its notes and blank lines.
|
||||
fn failure_text(stdout: &str, stderr: &str) -> String {
|
||||
let lines: Vec<&str> = stderr
|
||||
.lines()
|
||||
.chain(stdout.lines())
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("Note: Using configuration file"))
|
||||
.collect();
|
||||
lines.iter().take(20).copied().collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The diagnostics of each checked file (none for a clean one), matched to the report's files
|
||||
/// by their real paths. Errors PHPStan reports in a trait name the class using it
|
||||
/// (`Trait.php (in context of class Foo)`).
|
||||
fn diagnostics(files: &[PathBuf], reported: BTreeMap<String, Vec<Message>>) -> Vec<(PathBuf, Vec<Diagnostic>)> {
|
||||
let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
let mut messages: BTreeMap<PathBuf, Vec<Message>> = BTreeMap::new();
|
||||
for (name, list) in reported {
|
||||
let name = name.split(" (in context of ").next().unwrap_or(&name);
|
||||
messages.entry(real(Path::new(name))).or_default().extend(list);
|
||||
}
|
||||
files
|
||||
.iter()
|
||||
.map(|file| {
|
||||
let list = messages.remove(&real(file)).unwrap_or_default();
|
||||
// Columns only: a file in another encoding than UTF-8 still gives the right lines.
|
||||
let text = if list.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||
};
|
||||
let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect();
|
||||
(file.clone(), items)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic {
|
||||
Diagnostic {
|
||||
range: line_range(text, m.line),
|
||||
severity: Severity::Warning,
|
||||
message: m.text,
|
||||
source: Some("PHPStan".into()),
|
||||
server: Some(SOURCE.into()),
|
||||
code: m.identifier,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16).
|
||||
fn line_range(text: Option<&str>, line: u32) -> Range {
|
||||
let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t));
|
||||
let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end();
|
||||
let indent = content.len() - content.trim_start().len();
|
||||
let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX);
|
||||
Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{
|
||||
"/p/src/A.php":{"errors":2,"messages":[
|
||||
{"message":"Constant UNKNOWN not found.","line":11,"ignorable":true,
|
||||
"tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"},
|
||||
{"message":"Access to an undefined property.","line":3,"ignorable":true,
|
||||
"tip":"Learn more: <fg=cyan>https://phpstan.org/blog/solving</> for array<int, string>","identifier":"property.notFound"}]},
|
||||
"/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[
|
||||
{"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}},
|
||||
"errors":[]}"#;
|
||||
|
||||
fn message(line: u32, text: &str, identifier: &str) -> Message {
|
||||
Message { line, text: text.into(), identifier: Some(identifier.into()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_keep_their_line_tip_and_identifier() {
|
||||
let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap();
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(
|
||||
report.files["/p/src/A.php"],
|
||||
[
|
||||
message(
|
||||
10,
|
||||
"Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols",
|
||||
"constant.notFound"
|
||||
),
|
||||
message(
|
||||
2,
|
||||
"Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array<int, string>",
|
||||
"property.notFound"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
report.files["/p/src/T.php (in context of class App\\A)"],
|
||||
[message(0, "Syntax error", "phpstan.parse")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_of_the_run_are_not_file_messages() {
|
||||
let stdout =
|
||||
r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#;
|
||||
let report = parse(stdout, "").unwrap();
|
||||
assert!(report.files.is_empty());
|
||||
assert_eq!(report.errors, ["Invalid entries in excludePaths"]);
|
||||
// PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file.
|
||||
let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}");
|
||||
assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]);
|
||||
let marked = format!("\u{feff}{stdout}");
|
||||
assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]);
|
||||
assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]);
|
||||
assert!(parse(stdout, "").unwrap().complete);
|
||||
let severe = " Result is incomplete because of severe errors.";
|
||||
assert!(!parse(stdout, severe).unwrap().complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_without_a_report_is_a_failure() {
|
||||
let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n";
|
||||
assert_eq!(
|
||||
parse("", stderr),
|
||||
Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse("Fatal error: Allowed memory size exhausted", ""),
|
||||
Err("Fatal error: Allowed memory size exhausted".into())
|
||||
);
|
||||
assert_eq!(parse("", ""), Err(String::new()));
|
||||
// Excluded files are no failure: nothing to report.
|
||||
assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_files_get_their_messages_and_clean_files_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php"));
|
||||
std::fs::write(&a, "<?php\n\n echo $é; \r\n").unwrap();
|
||||
std::fs::write(&t, "<?php\n").unwrap();
|
||||
std::fs::write(&clean, "<?php\n").unwrap();
|
||||
let reported = BTreeMap::from([
|
||||
(a.to_string_lossy().into_owned(), vec![message(2, "Undefined variable", "variable.undefined")]),
|
||||
(format!("{} (in context of class A)", t.display()), vec![message(0, "In a trait", "trait.x")]),
|
||||
("/elsewhere/B.php".to_string(), vec![message(0, "Not checked", "x")]),
|
||||
]);
|
||||
let out = diagnostics(&[a.clone(), t.clone(), clean.clone()], reported);
|
||||
assert_eq!(
|
||||
out.iter().map(|(p, items)| (p.clone(), items.len())).collect::<Vec<_>>(),
|
||||
[(a, 1), (t, 1), (clean, 0)]
|
||||
);
|
||||
let d = &out[0].1[0];
|
||||
// From the first non-blank character to the end of the line, in UTF-16 columns.
|
||||
assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } });
|
||||
assert_eq!(
|
||||
(d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity),
|
||||
(Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning)
|
||||
);
|
||||
assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } });
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn burst_saves_give_one_check_of_all_files() {
|
||||
let mut queue = Queue::default();
|
||||
queue.push([PathBuf::from("/p/A.php")]);
|
||||
let token = queue.cancel.clone();
|
||||
let lifetime = CancellationToken::new();
|
||||
let pending_lifetime = lifetime.clone();
|
||||
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
|
||||
assert!(!waiting.is_finished());
|
||||
for i in 0..20 {
|
||||
queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]);
|
||||
}
|
||||
assert!(!waiting.await.unwrap());
|
||||
let current = queue.generation;
|
||||
assert!(debounce(&queue.cancel, &lifetime).await);
|
||||
assert!(queue.current(current));
|
||||
assert_eq!(queue.files.len(), 4);
|
||||
lifetime.cancel();
|
||||
assert!(!debounce(&queue.cancel, &lifetime).await);
|
||||
}
|
||||
|
||||
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
|
||||
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
|
||||
#[cfg(unix)]
|
||||
struct Fixture {
|
||||
_dir: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
trusted: Arc<AtomicBool>,
|
||||
checker: Arc<Phpstan>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(dir.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("vendor/bin/phpstan"), "<?php\n").unwrap();
|
||||
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
|
||||
let php = base.join("php/php8.5");
|
||||
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
|
||||
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
|
||||
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
|
||||
std::fs::write(&php, script).unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy());
|
||||
std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap();
|
||||
let config = base.join("config");
|
||||
let settings = Arc::new(SettingsStore::load(&config, Some(&root)));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e));
|
||||
let trusted = Arc::new(AtomicBool::new(true));
|
||||
let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone());
|
||||
checker.start(&tokio::runtime::Handle::current());
|
||||
Self { _dir: dir, root, events, trusted, checker }
|
||||
}
|
||||
|
||||
fn file(&self, name: &str) -> PathBuf {
|
||||
let path = self.root.join("src").join(name);
|
||||
std::fs::write(&path, "<?php\necho UNKNOWN;\n").unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn report(&self, path: &Path, identifiers: &[&str]) {
|
||||
let messages: Vec<Value> = identifiers
|
||||
.iter()
|
||||
.map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true}))
|
||||
.collect();
|
||||
let files = if messages.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} })
|
||||
};
|
||||
std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn runs(&self) -> Vec<String> {
|
||||
std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) {
|
||||
for _ in 0..200 {
|
||||
if done(self) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock());
|
||||
}
|
||||
|
||||
fn diagnostics(&self) -> Vec<(PathBuf, Vec<Option<String>>)> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path, source, items } if source == SOURCE => {
|
||||
Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notifications(&self) -> Vec<String> {
|
||||
let events = self.events.lock();
|
||||
events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
for _ in 0..5 {
|
||||
f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]);
|
||||
}
|
||||
f.until("one check", |f| f.diagnostics().len() == 1).await;
|
||||
assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]);
|
||||
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]);
|
||||
|
||||
// A save during a check kills it; the next check covers both files.
|
||||
std::fs::write(f.root.join("pause"), "30").unwrap();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the slow check", |f| f.runs().len() == 2).await;
|
||||
std::fs::write(f.root.join("pause"), "0").unwrap();
|
||||
let b = f.file("B.php");
|
||||
f.report(&a, &[]);
|
||||
f.checker.files_saved([b.clone()]);
|
||||
f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await;
|
||||
assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs());
|
||||
// A clean result clears A; B never had diagnostics, so nothing is sent for it.
|
||||
assert_eq!(f.diagnostics()[1], (a, vec![]));
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the first check", |f| f.diagnostics().len() == 1).await;
|
||||
|
||||
std::fs::write(f.root.join("report"), "").unwrap();
|
||||
for run in 2..=3 {
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the failing check", |f| f.runs().len() == run).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let notes = f.notifications();
|
||||
assert_eq!(notes.len(), 1, "{notes:?}");
|
||||
assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}");
|
||||
// The failed check leaves no stale errors.
|
||||
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]);
|
||||
|
||||
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
|
||||
let events = f.events.lock().len();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
tokio::time::sleep(DELAY * 2).await;
|
||||
assert_eq!(f.events.lock().len(), events);
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() {
|
||||
let f = Fixture::new();
|
||||
let (a, b) = (f.file("A.php"), f.file("B.php"));
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check of A", |f| f.diagnostics().len() == 1).await;
|
||||
// A syntax error in B: PHPStan reports B only and says the result is incomplete.
|
||||
f.report(&b, &["phpstan.parse"]);
|
||||
std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap();
|
||||
f.checker.files_saved([a.clone(), b.clone()]);
|
||||
f.until("the check of both", |f| f.diagnostics().len() == 2).await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())]));
|
||||
assert_eq!(f.diagnostics().len(), 2, "A is not cleared");
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() {
|
||||
let f = Fixture::new();
|
||||
let (a, b) = (f.file("A.php"), f.file("B.php"));
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
std::fs::write(f.root.join("pause"), "1").unwrap();
|
||||
f.checker.files_saved([a.clone(), b.clone()]);
|
||||
f.until("the check", |f| f.runs().len() == 1).await;
|
||||
std::fs::remove_file(&a).unwrap();
|
||||
// B is checked again on its own.
|
||||
f.until("the check of B", |f| f.runs().len() == 2).await;
|
||||
assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs());
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(f.events.lock().is_empty(), "{:?}", f.events.lock());
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_mode_stops_the_check_in_progress() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
std::fs::write(f.root.join("pause"), "30").unwrap();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check", |f| f.runs().len() == 1).await;
|
||||
f.trusted.store(false, Ordering::Release);
|
||||
f.checker.interrupt();
|
||||
tokio::time::sleep(DELAY * 2).await;
|
||||
assert_eq!(f.runs().len(), 1);
|
||||
assert!(f.events.lock().is_empty());
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_no_longer_checked_lose_their_diagnostics() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check", |f| f.diagnostics().len() == 1).await;
|
||||
// PHPStan removed from the project: saving A no longer checks it and drops its errors.
|
||||
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the clear", |f| f.diagnostics().len() == 2).await;
|
||||
assert_eq!(f.diagnostics()[1], (a, vec![]));
|
||||
assert_eq!(f.runs().len(), 1);
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn removed_files_lose_their_diagnostics() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check", |f| f.diagnostics().len() == 1).await;
|
||||
f.checker.files_removed([f.root.join("src/other")].iter());
|
||||
assert_eq!(f.diagnostics().len(), 1);
|
||||
f.checker.files_removed([f.root.join("src")].iter());
|
||||
assert_eq!(f.diagnostics()[1], (a, vec![]));
|
||||
f.checker.stop();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
@@ -29,6 +30,11 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn projects(&self) -> Vec<PathBuf>;
|
||||
/// Unsaved text of the open document at `path`.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
|
||||
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
|
||||
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
|
||||
None
|
||||
}
|
||||
/// Version and text of the open document at `path`.
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)>;
|
||||
/// Applies `edits` to the open document at `path` if it is still at
|
||||
@@ -305,8 +311,9 @@ impl PluginApi {
|
||||
// allowed, and installing it later approves nothing by itself.
|
||||
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
|
||||
HostError::Failed(format!(
|
||||
"{cap}: `{program}` was not found when this permission was allowed; \
|
||||
install it, then allow {cap} again in Settings › Plugins"
|
||||
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
|
||||
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
|
||||
clear {cap} in Settings › Plugins and select it again"
|
||||
))
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
|
||||
@@ -324,6 +331,18 @@ impl HostApi for PluginApi {
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "copy to the clipboard")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "open a URL")?;
|
||||
self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn status_item(&self, id: &str, item: Option<StatusItem>) {
|
||||
let key = (self.id().to_owned(), id.to_owned());
|
||||
{
|
||||
@@ -407,7 +426,7 @@ impl HostApi for PluginApi {
|
||||
let info = repo.branch_info().map_err(failed)?;
|
||||
let changed = repo.status().map_err(failed)?.len();
|
||||
Ok(Some(RepositoryStatus {
|
||||
root: repo.root().to_string_lossy().into_owned(),
|
||||
root: to_ipc(repo.root()),
|
||||
branch: info.head,
|
||||
commit: info.head_commit,
|
||||
upstream: info.upstream,
|
||||
@@ -418,6 +437,12 @@ impl HostApi for PluginApi {
|
||||
}))
|
||||
}
|
||||
|
||||
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
|
||||
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
|
||||
let remotes = repo.remotes().map_err(failed)?;
|
||||
Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
|
||||
}
|
||||
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
|
||||
let workspace = self.shared.workspace();
|
||||
if !workspace.is_some_and(|w| w.trusted(root)) {
|
||||
@@ -439,6 +464,19 @@ impl HostApi for PluginApi {
|
||||
.build()
|
||||
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
|
||||
.compile_matcher();
|
||||
// Hidden files and folders are left out, as in the walk below.
|
||||
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
|
||||
// The project index already lists the files: walking a large tree on every call can take
|
||||
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
|
||||
if let Some(files) = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
|
||||
{
|
||||
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
|
||||
out.sort();
|
||||
return Ok(out);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
|
||||
if n >= MAX_WALK || out.len() >= limit {
|
||||
@@ -449,7 +487,7 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
|
||||
if matcher.is_match(rel) {
|
||||
out.push(entry.path().to_string_lossy().into_owned());
|
||||
out.push(to_ipc(entry.path()));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -718,6 +756,12 @@ impl PluginWorkspace for HostWorkspace {
|
||||
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
|
||||
}
|
||||
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
// The index of the project rooted at `root` (a workspace member's files are in its own).
|
||||
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
|
||||
project.index.files_matching(root, matches, limit)
|
||||
}
|
||||
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
let (project, doc) = self.open_document(path)?;
|
||||
let snap = project.documents.snapshot(doc).ok()?;
|
||||
@@ -788,6 +832,73 @@ mod tests {
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_and_search_paths_match_ipc_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
std::fs::create_dir(root.join("src")).unwrap();
|
||||
let file = root.join("src/Example.java");
|
||||
std::fs::write(&file, "class Example {}\n").unwrap();
|
||||
let api = api(&root, Arc::default());
|
||||
assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
|
||||
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
|
||||
}
|
||||
|
||||
/// A project index that lists `files` (root-relative), whatever is on disk.
|
||||
struct Indexed(PathBuf, Vec<&'static str>);
|
||||
impl PluginWorkspace for Indexed {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.0.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::Stale)
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
(root == self.0).then(|| {
|
||||
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_files_uses_the_project_index_when_there_is_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let api = api(&root, Arc::default());
|
||||
*api.shared.workspace.write() = Some(Arc::new(Indexed(
|
||||
root.clone(),
|
||||
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
|
||||
)));
|
||||
// Listed by the index, not walked: none of these exist on disk.
|
||||
assert_eq!(
|
||||
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
|
||||
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
|
||||
);
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
|
||||
// Another root: no index covers it, so the disk is walked.
|
||||
let other = root.join("other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(other.join("a.xml"), "").unwrap();
|
||||
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_run_only_as_approved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -799,7 +910,8 @@ mod tests {
|
||||
// Allowed while the program was missing: say how to fix it rather than "not granted".
|
||||
let err = api.approved_program("lint").unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
|
||||
err.contains("was not found on PATH when this permission was allowed")
|
||||
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
@@ -812,3 +924,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a user action invoked the plugin from: browser tabs and clipboard writes
|
||||
/// follow what the user did, never background work (which would reach every window).
|
||||
fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
|
||||
client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
|
||||
}
|
||||
|
||||
/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
|
||||
/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
|
||||
fn without_credentials(url: &str) -> String {
|
||||
let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
|
||||
let authority_end = rest.find('/').unwrap_or(rest.len());
|
||||
match rest[..authority_end].rfind('@') {
|
||||
Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
|
||||
None => url.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod credential_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_urls_lose_their_credentials() {
|
||||
for (url, expected) in [
|
||||
("https://user:[email protected]/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://[email protected]/o/r", "https://gitlab.com/o/r"),
|
||||
("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
|
||||
("https://github.com/o/r.git", "https://github.com/o/r.git"),
|
||||
("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
|
||||
("[email protected]:o/r.git", "[email protected]:o/r.git"),
|
||||
("/srv/git/r.git", "/srv/git/r.git"),
|
||||
] {
|
||||
assert_eq!(without_credentials(url), expected, "{url}");
|
||||
}
|
||||
assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
|
||||
assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_plugin::Invocation;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct NavigationFiles {
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
pub indexing: bool,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// File discovery is separate from clicking: the UI schedules small background
|
||||
/// batches after initial index completion and updates only changed files later.
|
||||
pub async fn plugin_navigation_files(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
) -> IdeResult<NavigationFiles> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let extensions = plugins.navigation_extensions(&project.root);
|
||||
if extensions.is_empty() {
|
||||
return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] });
|
||||
}
|
||||
let indexing = project.index.status().indexing;
|
||||
let files = project.index.files_with_extensions(&extensions, 20_001);
|
||||
if files.len() > 20_000 {
|
||||
return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files"));
|
||||
}
|
||||
Ok(NavigationFiles {
|
||||
paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(),
|
||||
indexing,
|
||||
extensions,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn plugin_navigation_invalidate(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_index(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
request: ide_core::RequestId,
|
||||
) -> IdeResult<()> {
|
||||
if paths.len() > 16 {
|
||||
return Err(IdeError::invalid("navigation batches are limited to 16 files"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let client = window.to_owned();
|
||||
let result = blocking(move || {
|
||||
if !project.is_trusted() {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
for path in paths {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else {
|
||||
continue;
|
||||
};
|
||||
let extensions = plugins.navigation_extensions(&project.root);
|
||||
if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) {
|
||||
continue;
|
||||
}
|
||||
let document = project.documents.by_path(&path);
|
||||
let (text, version, language) = if let Some(id) = document {
|
||||
let snapshot = project.documents.text_snapshot(id)?;
|
||||
if snapshot.large {
|
||||
continue;
|
||||
}
|
||||
(snapshot.text, Some(snapshot.version), snapshot.language)
|
||||
} else {
|
||||
use std::io::Read;
|
||||
if !std::fs::metadata(&path)
|
||||
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(&path) else {
|
||||
continue;
|
||||
};
|
||||
let mut text = String::new();
|
||||
if file
|
||||
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
|
||||
.read_to_string(&mut text)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let language = project.languages.detect(&path, text.lines().next());
|
||||
(text, None, language)
|
||||
};
|
||||
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let symbols = project
|
||||
.index
|
||||
.symbols_for_text(&path, &text)
|
||||
.unwrap_or_else(|| project.syntax.syntax().tags(&language, &text));
|
||||
let roots = project.roots();
|
||||
let scope = roots
|
||||
.roots
|
||||
.iter()
|
||||
.filter(|r| path.starts_with(project.root.join(&r.path)))
|
||||
.max_by_key(|r| r.path.len())
|
||||
.and_then(|r| r.detected_from.as_deref())
|
||||
.unwrap_or("");
|
||||
plugins.index_navigation(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation { client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None },
|
||||
)?;
|
||||
// A document edit while WASM was executing must not publish stale ranges.
|
||||
if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path]));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_goto(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
doc: ide_core::DocId,
|
||||
pos: ide_core::Pos,
|
||||
) -> IdeResult<Option<crate::plugins::navigation::NavigationResult>> {
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
let (path, version) = project.documents.identity(doc)?;
|
||||
Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_markers(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<crate::plugins::navigation::FileMarkers>> {
|
||||
if paths.len() > 64 {
|
||||
return Err(IdeError::invalid("marker batches are limited to 64 files"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
@@ -422,7 +422,8 @@ struct IdeDir {
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
|
||||
/// per product: its newest version's (an upgrade leaves the older ones behind).
|
||||
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let (config_root, data_root) = match layout {
|
||||
@@ -462,6 +463,8 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
|
||||
let mut products = std::collections::HashSet::new();
|
||||
out.retain(|ide| products.insert(ide.product.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -784,6 +787,13 @@ mod tests {
|
||||
};
|
||||
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
|
||||
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
|
||||
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
|
||||
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
|
||||
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
|
||||
|
||||
@@ -20,9 +20,11 @@ mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod market;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
@@ -41,8 +43,8 @@ pub use market::{
|
||||
read_url as read_market_url, resolve as resolve_market_href,
|
||||
};
|
||||
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
|
||||
pub(crate) use providers::editor_providers as editor_providers_for;
|
||||
pub use providers::{ProviderKind, set_editor_providers};
|
||||
pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for};
|
||||
pub use theme::{ThemeDef, ThemeKind};
|
||||
pub use types::*;
|
||||
|
||||
@@ -308,6 +310,7 @@ pub struct PluginHost {
|
||||
run_detectors: Arc<run_detectors::RunDetectors>,
|
||||
/// `[[contributes.providers]]` of enabled plugins.
|
||||
providers: Arc<providers::Providers>,
|
||||
navigation: Mutex<navigation::NavigationIndex>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
@@ -396,6 +399,7 @@ impl PluginHost {
|
||||
languages,
|
||||
run_detectors,
|
||||
providers,
|
||||
navigation: Mutex::default(),
|
||||
index,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
@@ -1881,6 +1885,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
let Some(error) = actor.take_crash() else { return };
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
if error.starts_with(CANCELLED) {
|
||||
// Cancelled on request: restart on next use, no backoff.
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -1952,6 +1957,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(true);
|
||||
}
|
||||
@@ -1961,6 +1967,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "{error}");
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -1969,6 +1976,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(false);
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
//! API 1.x document indexers: background snapshots become project-scoped
|
||||
//! associations. Navigation and gutter markers only read validated memory indexes.
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult, Location, Pos, Range};
|
||||
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
|
||||
use ide_language::SymbolTag;
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
|
||||
const MAX_FILES: usize = 20_000;
|
||||
const MAX_EDGES: usize = 4096;
|
||||
|
||||
pub struct Snapshot<'a> {
|
||||
pub path: &'a Path,
|
||||
pub version: Option<u32>,
|
||||
pub text: &'a str,
|
||||
pub symbols: &'a [SymbolTag],
|
||||
pub scope: &'a str,
|
||||
}
|
||||
|
||||
pub use ide_plugin::indexing::*;
|
||||
|
||||
struct File {
|
||||
version: Option<u32>,
|
||||
scope: String,
|
||||
edges: Edges,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Graph {
|
||||
instance: Weak<super::LoadedPlugin>,
|
||||
files: BTreeMap<PathBuf, File>,
|
||||
definitions: HashMap<String, Vec<Location>>,
|
||||
aliases: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
edges: usize,
|
||||
}
|
||||
impl Graph {
|
||||
fn targets(&self, key: &str) -> Vec<&Location> {
|
||||
let mut keys = vec![key.to_owned()];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut targets = vec![];
|
||||
while let Some(key) = keys.pop() {
|
||||
if seen.len() >= 64 || !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(locations) = self.definitions.get(&key) {
|
||||
targets.extend(locations.iter().take(256usize.saturating_sub(targets.len())));
|
||||
continue;
|
||||
}
|
||||
if let Some((prefix, suffix)) = key.split_once('#')
|
||||
&& let Some(aliases) = self.aliases.get(&format!("{prefix}#"))
|
||||
{
|
||||
for (_, target) in aliases.iter().take(64) {
|
||||
keys.push(format!("{target}{suffix}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
fn remove(&mut self, path: &Path) {
|
||||
if let Some(file) = self.files.remove(path) {
|
||||
self.edges -= file.edges.definitions.len()
|
||||
+ file.edges.references.len()
|
||||
+ file.edges.aliases.len()
|
||||
+ file.edges.markers.len();
|
||||
for alias in file.edges.aliases {
|
||||
if let Some(entries) = self.aliases.get_mut(&alias.prefix) {
|
||||
entries.retain(|(p, _)| p != path);
|
||||
if entries.is_empty() {
|
||||
self.aliases.remove(&alias.prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
for anchor in file.edges.definitions {
|
||||
if let Some(locations) = self.definitions.get_mut(&anchor.key) {
|
||||
locations.retain(|l| l.path.0.as_path() != path);
|
||||
if locations.is_empty() {
|
||||
self.definitions.remove(&anchor.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn insert(&mut self, path: PathBuf, file: File) {
|
||||
self.remove(&path);
|
||||
self.edges += file.edges.definitions.len()
|
||||
+ file.edges.references.len()
|
||||
+ file.edges.aliases.len()
|
||||
+ file.edges.markers.len();
|
||||
for alias in &file.edges.aliases {
|
||||
self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone()));
|
||||
}
|
||||
for anchor in &file.edges.definitions {
|
||||
self.definitions.entry(anchor.key.clone()).or_default().push(Location {
|
||||
path: path.clone().into(),
|
||||
range: anchor.range,
|
||||
uri: None,
|
||||
});
|
||||
}
|
||||
self.files.insert(path, file);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct NavigationIndex {
|
||||
projects: HashMap<(PathBuf, Option<String>), HashMap<String, Graph>>,
|
||||
generation: u64,
|
||||
epochs: HashMap<PathBuf, u64>,
|
||||
}
|
||||
|
||||
fn contains(range: Range, pos: Pos) -> bool {
|
||||
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
fn valid_range(lines: &[usize], range: Range) -> bool {
|
||||
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn clear_plugin_navigation(&self, id: &str) {
|
||||
let mut index = self.navigation.lock();
|
||||
index.generation = index.generation.wrapping_add(1);
|
||||
let epoch = index.generation;
|
||||
let roots: Vec<_> = index.projects.keys().cloned().collect();
|
||||
for root in roots {
|
||||
index.epochs.insert(root.0, epoch);
|
||||
}
|
||||
for graphs in index.projects.values_mut() {
|
||||
graphs.remove(id);
|
||||
}
|
||||
drop(index);
|
||||
self.emit(None, super::PluginEvent::DocumentIndexesChanged);
|
||||
}
|
||||
|
||||
/// Extensions with an active, permissioned navigation provider for this root.
|
||||
pub fn navigation_extensions(&self, root: &Path) -> Vec<String> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return vec![];
|
||||
}
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root))
|
||||
.filter_map(|p| declaration(p))
|
||||
.flat_map(|d| d.extensions)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove stale contributions before scheduling replacement work. `None`
|
||||
/// disposes the project, including reverse-index entries, on project close.
|
||||
pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) {
|
||||
self.invalidate_navigation_scope(root, None, paths);
|
||||
}
|
||||
|
||||
pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) {
|
||||
self.invalidate_navigation_scope(root, Some(client), paths);
|
||||
}
|
||||
|
||||
fn invalidate_navigation_scope(&self, root: &Path, client: Option<Option<&str>>, paths: Option<&[PathBuf]>) {
|
||||
let matches = |key: &(PathBuf, Option<String>)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c);
|
||||
let mut index = self.navigation.lock();
|
||||
index.generation = index.generation.wrapping_add(1);
|
||||
let epoch = index.generation;
|
||||
index.epochs.insert(root.to_owned(), epoch);
|
||||
if index.epochs.len() > 64
|
||||
&& let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone())
|
||||
{
|
||||
index.epochs.remove(&oldest);
|
||||
}
|
||||
match paths {
|
||||
None => {
|
||||
index.projects.retain(|key, _| !matches(key));
|
||||
}
|
||||
Some(paths) => {
|
||||
for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) {
|
||||
for graph in graphs.values_mut() {
|
||||
for path in paths {
|
||||
graph.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index one bounded snapshot. No directory traversal or parsing happens in
|
||||
/// navigation_at. Call off the UI thread, in small cancellable batches.
|
||||
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
|
||||
let Snapshot { path, version, text, symbols, scope } = snapshot;
|
||||
if !(self.project_policy.read())(root) {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
if text.len() > MAX_DOCUMENT_BYTES {
|
||||
return Err(IdeError::invalid("navigation document is too large"));
|
||||
}
|
||||
let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?;
|
||||
if real != path {
|
||||
return Err(IdeError::invalid("navigation requires a canonical project path"));
|
||||
}
|
||||
let project_key = (root.to_owned(), invocation.client.clone());
|
||||
let generation = self.navigation.lock().epochs.get(root).copied();
|
||||
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
|
||||
let plugins: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| {
|
||||
p.status().is_live()
|
||||
&& p.dev_root.as_deref().is_none_or(|r| r == root)
|
||||
&& p.meta.lock().granted.contains(&Capability::ProjectRead)
|
||||
&& declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if plugins.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect();
|
||||
for plugin in plugins {
|
||||
let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned());
|
||||
let symbols = symbols
|
||||
.iter()
|
||||
.map(|s| ide_plugin::indexing::IndexSymbol {
|
||||
name: s.name.clone(),
|
||||
kind: serde_json::to_value(s.kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_default(),
|
||||
container: s.container.clone(),
|
||||
range: s.range,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ctx = invocation.clone();
|
||||
let started = std::time::Instant::now();
|
||||
let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx));
|
||||
let edges = self
|
||||
.finish_provider_call(&plugin, &actor, started, result)
|
||||
.ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?;
|
||||
let output_bytes = edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&edges.references)
|
||||
.chain(&edges.markers)
|
||||
.map(|a| a.key.len().saturating_add(32))
|
||||
.sum::<usize>()
|
||||
.saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::<usize>());
|
||||
if output_bytes > MAX_DOCUMENT_BYTES {
|
||||
return Err(IdeError::invalid("document index output is too large"));
|
||||
}
|
||||
if edges.aliases.len() > 256
|
||||
|| edges.aliases.iter().any(|a| {
|
||||
!a.prefix.ends_with('#')
|
||||
|| !a.target.ends_with('#')
|
||||
|| a.prefix.len() > 1024
|
||||
|| a.target.len() > 1024
|
||||
})
|
||||
|| edges.definitions.len() + edges.references.len() > MAX_EDGES
|
||||
|| edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&edges.references)
|
||||
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation anchors"));
|
||||
}
|
||||
if edges.markers.len() > MAX_EDGES
|
||||
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation markers"));
|
||||
}
|
||||
let mut index = self.navigation.lock();
|
||||
if index.epochs.get(root).copied() != generation {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Keep a bounded number of project graphs, including closed windows
|
||||
// whose frontend did not get an opportunity to dispose its state.
|
||||
if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) {
|
||||
return Err(IdeError::unavailable("navigation project limit reached"));
|
||||
}
|
||||
let graph =
|
||||
index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default();
|
||||
if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) {
|
||||
*graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() };
|
||||
}
|
||||
if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) {
|
||||
return Err(IdeError::unavailable("navigation file limit reached"));
|
||||
}
|
||||
graph.remove(path);
|
||||
if graph.edges
|
||||
+ edges.definitions.len()
|
||||
+ edges.references.len()
|
||||
+ edges.aliases.len()
|
||||
+ edges.markers.len()
|
||||
> 200_000
|
||||
{
|
||||
return Err(IdeError::unavailable("navigation association limit reached"));
|
||||
}
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves only current live providers. Disable/reload/trap cannot retain
|
||||
/// navigable stale data, even if a background snapshot finished concurrently.
|
||||
pub fn navigation_at(
|
||||
&self,
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
version: u32,
|
||||
pos: Pos,
|
||||
client: Option<&str>,
|
||||
version_of: impl Fn(&Path) -> Option<u32>,
|
||||
) -> Option<NavigationResult> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return None;
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
|
||||
let mut result: Option<NavigationResult> = None;
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = graph.files.get(path) else { continue };
|
||||
if file.version != Some(version) {
|
||||
continue;
|
||||
}
|
||||
for reference in &file.edges.references {
|
||||
if !contains(reference.range, pos) {
|
||||
continue;
|
||||
}
|
||||
let targets = graph.targets(&reference.key);
|
||||
if targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] });
|
||||
let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
|
||||
for target in targets.iter().take(256) {
|
||||
let Some(target_file) = graph.files.get(&target.path.0) else {
|
||||
continue;
|
||||
};
|
||||
if local && target_file.scope != file.scope {
|
||||
continue;
|
||||
}
|
||||
if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
if (target.path.0.as_path() != path || !contains(target.range, pos))
|
||||
&& !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range)
|
||||
{
|
||||
answer.targets.push((*target).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.filter(|r| !r.targets.is_empty())
|
||||
}
|
||||
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
|
||||
pub fn navigation_markers(
|
||||
&self,
|
||||
root: &Path,
|
||||
paths: &[PathBuf],
|
||||
client: Option<&str>,
|
||||
version_of: impl Fn(&Path) -> Option<u32>,
|
||||
) -> Vec<FileMarkers> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return vec![];
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut result = vec![];
|
||||
for path in paths {
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = graph.files.get(path) else { continue };
|
||||
if version_of(path).is_some_and(|v| file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
let mut markers = vec![];
|
||||
for anchor in &file.edges.markers {
|
||||
let candidates = graph.targets(&anchor.key);
|
||||
let local =
|
||||
candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
|
||||
let mut targets = vec![];
|
||||
for target in candidates {
|
||||
let Some(other) = graph.files.get(&target.path.0) else { continue };
|
||||
if (local && other.scope != file.scope)
|
||||
|| version_of(&target.path).is_some_and(|v| other.version != Some(v))
|
||||
|| (target.path.0 == *path && target.range == anchor.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) {
|
||||
targets.push(target.clone());
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
markers.push(NavigationResult { range: anchor.range, targets });
|
||||
}
|
||||
}
|
||||
if !markers.is_empty() {
|
||||
result.push(FileMarkers { path: path.clone(), version: file.version, markers });
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
extensions: Vec<String>,
|
||||
}
|
||||
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
|
||||
p.manifest()
|
||||
.declarations("documentIndexers")
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|d| decode("documentIndexers", d.clone()).ok())
|
||||
}
|
||||
pub struct IndexersPoint;
|
||||
impl ExtensionPoint for IndexersPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
"documentIndexers"
|
||||
}
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Workspace
|
||||
}
|
||||
fn prepare(&self, pkg: &Package, value: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Decl = decode(self.id(), value)?;
|
||||
if pkg.manifest.wasm.is_none()
|
||||
|| d.extensions.is_empty()
|
||||
|| d.extensions.len() > 32
|
||||
|| d.extensions
|
||||
.iter()
|
||||
.any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric()))
|
||||
{
|
||||
return Err("documentIndexers requires wasm and 1-32 file extensions".into());
|
||||
}
|
||||
if pkg.manifest.declarations(self.id()).len() > 1 {
|
||||
return Err("declare one document indexer per plugin".into());
|
||||
}
|
||||
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
|
||||
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
|
||||
registry.register(Arc::new(super::navigation::IndexersPoint))?;
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
use super::ui_points as p;
|
||||
|
||||
@@ -180,10 +180,14 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
|
||||
let host = editor_plugin_host()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
EDITOR_PROVIDERS.read().as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Usable plugins declaring `kind` for `language`.
|
||||
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
|
||||
@@ -115,6 +115,8 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -446,6 +448,44 @@ impl RemotePlugins {
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_files", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move {
|
||||
super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?)
|
||||
} });
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_index", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_index(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("paths")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_goto", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_markers", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
|
||||
@@ -153,6 +153,8 @@ pub struct PluginUpdate {
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// Cached document associations were disposed (reload, disable or failure).
|
||||
DocumentIndexesChanged,
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
@@ -197,6 +199,12 @@ pub enum PluginEvent {
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
/// Copy `text` to the system clipboard.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CopyToClipboard { plugin_id: String, text: String },
|
||||
/// Open `url` (`http(s)://`) in the system browser.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenUrl { plugin_id: String, url: String },
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
|
||||
+144
-12
@@ -55,6 +55,7 @@ pub struct Project {
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -77,6 +78,8 @@ impl Host {
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -230,6 +233,11 @@ impl Host {
|
||||
if cfg.name == "jdtls" {
|
||||
let (excluded_poms, build_files) = jdtls_maven_import(root, &effective);
|
||||
ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref());
|
||||
ide_sdk::apply::configure_gradle_import(
|
||||
cfg,
|
||||
effective.languages.java.gradle.linked_projects.as_deref(),
|
||||
build_files.as_deref(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -285,12 +293,21 @@ impl Host {
|
||||
trusted.clone(),
|
||||
);
|
||||
documents.add_observer(kotlin_bridge.clone());
|
||||
let phpstan = crate::phpstan::Phpstan::new(
|
||||
root.clone(),
|
||||
self.config_dir.clone(),
|
||||
settings.clone(),
|
||||
sink.clone(),
|
||||
trusted.clone(),
|
||||
);
|
||||
documents.add_observer(phpstan.clone());
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
@@ -314,6 +331,7 @@ impl Host {
|
||||
let (ignored, ignored_gone) = ignored_changes(batch);
|
||||
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
phpstan.files_removed(batch.removed.iter());
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs);
|
||||
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
|
||||
@@ -433,6 +451,7 @@ impl Host {
|
||||
&history,
|
||||
&lsp,
|
||||
Some(&kotlin_bridge),
|
||||
Some(&phpstan),
|
||||
)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
@@ -479,6 +498,7 @@ impl Host {
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: Some(kotlin_bridge),
|
||||
phpstan: Some(phpstan),
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
@@ -506,9 +526,13 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.start(&self.runtime);
|
||||
}
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
@@ -564,6 +588,8 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
@@ -617,6 +643,7 @@ impl Host {
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
@@ -876,8 +903,14 @@ impl Project {
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
|
||||
linked_roots(&self.root, &self.settings.effective(), &detected)
|
||||
let settings = self.settings.effective();
|
||||
let detected = with_gradle_roots(
|
||||
&self.root,
|
||||
&self.build_cache_dir(),
|
||||
&settings.languages.java.gradle,
|
||||
&self.detected_roots.read(),
|
||||
);
|
||||
linked_roots(&self.root, &settings, &detected)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
@@ -987,6 +1020,7 @@ impl Project {
|
||||
&self.history,
|
||||
&self.lsp,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
self.phpstan.as_ref(),
|
||||
) {
|
||||
watchers.push((r, w));
|
||||
}
|
||||
@@ -1078,7 +1112,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
}
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
@@ -1122,8 +1156,9 @@ fn content_root_watcher(
|
||||
history: &Arc<LocalHistory>,
|
||||
lsp: &Arc<LspManager>,
|
||||
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
phpstan: Option<&Arc<crate::phpstan::Phpstan>>,
|
||||
) -> Option<Watcher> {
|
||||
let kotlin_bridge = kotlin_bridge.cloned();
|
||||
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let root = dir.to_path_buf();
|
||||
Watcher::start(
|
||||
@@ -1141,6 +1176,9 @@ fn content_root_watcher(
|
||||
if let Some(bridge) = &kotlin_bridge {
|
||||
bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
}
|
||||
if let Some(phpstan) = &phpstan {
|
||||
phpstan.files_removed(batch.removed.iter());
|
||||
}
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
@@ -1182,31 +1220,93 @@ pub(crate) fn jdtls_maven_import(
|
||||
settings: &ide_core::settings::Settings,
|
||||
) -> (Vec<PathBuf>, Option<Vec<String>>) {
|
||||
let maven = &settings.languages.java.maven;
|
||||
if maven.unlinked_projects.is_empty() {
|
||||
let gradle = &settings.languages.java.gradle;
|
||||
if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
|
||||
return (Vec::new(), None);
|
||||
}
|
||||
let model = ide_build::maven::load(root);
|
||||
let model = ide_build::maven::load_linked(root, maven);
|
||||
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
|
||||
let excluded = unlinked.poms().map(PathBuf::from).collect();
|
||||
let build_files = unlinked.import_poms(&model).map(|poms| {
|
||||
let gradle = ide_build::gradle::load(root).and_then(|g| {
|
||||
let poms = unlinked.import_poms(&model).or_else(|| {
|
||||
gradle.linked_projects.as_ref().map(|_| {
|
||||
model
|
||||
.projects
|
||||
.iter()
|
||||
.filter(|p| !p.aggregated && !unlinked.contains(&p.pom))
|
||||
.map(|p| p.pom.clone())
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
let build_files = poms.map(|poms| {
|
||||
let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| {
|
||||
let dir = root.join(&g.dir);
|
||||
let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES);
|
||||
names.map(|n| dir.join(n)).find(|f| f.is_file())
|
||||
});
|
||||
poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect()
|
||||
let eclipse = if settings.languages.java.gradle.linked_projects.is_some() {
|
||||
independent_eclipse_projects(root)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
poms.iter()
|
||||
.map(|pom| root.join(pom))
|
||||
.chain(gradle)
|
||||
.chain(eclipse)
|
||||
.map(|f| ide_lsp::path_to_uri_string(&f))
|
||||
.collect()
|
||||
});
|
||||
(excluded, build_files)
|
||||
}
|
||||
|
||||
/// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata.
|
||||
fn independent_eclipse_projects(root: &Path) -> Vec<PathBuf> {
|
||||
let managed = |dir: &Path| {
|
||||
std::iter::once("pom.xml")
|
||||
.chain(ide_build::gradle::SETTINGS_FILES.iter().copied())
|
||||
.chain(ide_build::gradle::BUILD_FILES.iter().copied())
|
||||
.any(|name| dir.join(name).is_file())
|
||||
};
|
||||
if managed(root) {
|
||||
return Vec::new();
|
||||
}
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.standard_filters(false)
|
||||
.filter_entry(move |entry| {
|
||||
if !entry.file_type().is_some_and(|kind| kind.is_dir()) {
|
||||
return false;
|
||||
}
|
||||
if entry.depth() > 0
|
||||
&& (entry.file_name().to_string_lossy().starts_with('.')
|
||||
|| matches!(
|
||||
entry.file_name().to_str(),
|
||||
Some("node_modules" | "target" | "build" | "dist" | "out" | "bin" | "vendor")
|
||||
)
|
||||
|| entry.path().parent().is_some_and(|parent| parent.join(".project").is_file()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!managed(entry.path())
|
||||
})
|
||||
.build();
|
||||
let mut projects: Vec<_> =
|
||||
walker.flatten().map(|entry| entry.path().join(".project")).filter(|path| path.is_file()).collect();
|
||||
projects.sort();
|
||||
projects
|
||||
}
|
||||
|
||||
/// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what
|
||||
/// static detection guesses. Folders outside the project keep their absolute path, which
|
||||
/// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`).
|
||||
fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec<SourceFolder> {
|
||||
fn with_gradle_roots(
|
||||
root: &Path,
|
||||
build_cache: &Path,
|
||||
settings: &ide_core::settings::GradleSettings,
|
||||
detected: &[SourceFolder],
|
||||
) -> Vec<SourceFolder> {
|
||||
use ide_build::gradle::ReportedRoot;
|
||||
use ide_workspace::roots::RootKind;
|
||||
let mut out: Vec<SourceFolder> = Vec::new();
|
||||
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
|
||||
for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) {
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
@@ -1376,6 +1476,37 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
#[test]
|
||||
fn explicit_gradle_links_preserve_independent_eclipse_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in
|
||||
["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"]
|
||||
{
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join(".project"), "<projectDescription/>").unwrap();
|
||||
}
|
||||
for dir in ["linked", "unlinked"] {
|
||||
std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap();
|
||||
}
|
||||
std::fs::write(root.join("maven/pom.xml"), "<project/>").unwrap();
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.languages.java.gradle.linked_projects = Some(vec!["linked".into()]);
|
||||
let (_, files) = super::jdtls_maven_import(root, &settings);
|
||||
let files = files.unwrap();
|
||||
let uri = |path: &str| ide_lsp::path_to_uri_string(&root.join(path));
|
||||
assert!(files.contains(&uri("linked/settings.gradle")));
|
||||
assert!(files.contains(&uri("maven/pom.xml")));
|
||||
assert!(files.contains(&uri("eclipse/.project")));
|
||||
assert!(files.contains(&uri("nested/eclipse/.project")));
|
||||
assert!(!files.iter().any(|path| path.contains("unlinked") || path.contains("build/generated")));
|
||||
assert!(!files.contains(&uri("linked/.project")));
|
||||
assert!(!files.contains(&uri("maven/.project")));
|
||||
settings.languages.java.gradle.linked_projects = Some(vec![]);
|
||||
let (_, files) = super::jdtls_maven_import(root, &settings);
|
||||
assert_eq!(files.unwrap(), [uri("maven/pom.xml"), uri("eclipse/.project"), uri("nested/eclipse/.project")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1473,7 +1604,8 @@ mod gradle_roots_tests {
|
||||
std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap();
|
||||
let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None };
|
||||
let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None };
|
||||
let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]);
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]);
|
||||
let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p));
|
||||
let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect();
|
||||
assert_eq!(
|
||||
|
||||
@@ -109,6 +109,9 @@ impl crate::Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.interrupt();
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
@@ -118,6 +121,7 @@ impl crate::Host {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
project.lsp.prewarm_java_configuration();
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
@@ -217,6 +221,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "recent_project_groups"
|
||||
| "set_recent_project_groups"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
@@ -249,6 +255,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_bootstrap_list"
|
||||
| "wsl_distros"
|
||||
| "wsl_resolve"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
@@ -312,7 +320,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_log_users"
|
||||
| "vcs_log_details"
|
||||
| "vcs_log_details_batch"
|
||||
| "vcs_log_index"
|
||||
| "vcs_revision_children"
|
||||
| "vcs_revision_files"
|
||||
| "vcs_open_external_log"
|
||||
@@ -332,7 +339,9 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_compare_scope"
|
||||
| "vcs_compare_paths"
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_diff_numstat"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_has_hook"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Docker run targets through `ops::run::start`, with a stand-in `docker`
|
||||
//! (`OPENIDEA_DOCKER`) that prints what it was given.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn host(base: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(target: &str, root: &Path) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "app".into(),
|
||||
name: "app".into(),
|
||||
kind: "python".into(),
|
||||
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
|
||||
args: vec![root.join("main.py").to_string_lossy().into_owned()],
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
async fn output_of(host: &Host, config: RunConfig) -> String {
|
||||
let out = Arc::new(Mutex::new(String::new()));
|
||||
let sink = out.clone();
|
||||
ops::run::start(
|
||||
host,
|
||||
"main",
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
if out.lock().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
out.lock().clone()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn docker_targets_run_through_the_docker_cli() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let docker = base.join("docker");
|
||||
std::fs::write(
|
||||
&docker,
|
||||
"#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\
|
||||
for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
// SAFETY: the only test of this binary, before any thread looks docker up.
|
||||
unsafe {
|
||||
std::env::set_var("OPENIDEA_DOCKER", &docker);
|
||||
std::env::remove_var("DOCKER_HOST");
|
||||
}
|
||||
|
||||
let root = base.join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = host(&base.join("app"));
|
||||
std::fs::create_dir_all(&host.data_dir).unwrap();
|
||||
std::fs::write(
|
||||
host.data_dir.join("docker.json"),
|
||||
r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let targets = serde_json::json!([
|
||||
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" },
|
||||
{ "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" },
|
||||
]);
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"runTargets",
|
||||
&targets.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let out = output_of(&host, config("py", &root)).await;
|
||||
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
|
||||
assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}");
|
||||
assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}");
|
||||
assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}");
|
||||
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
|
||||
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
|
||||
|
||||
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
|
||||
assert!(err.message.contains("another machine"), "{}", err.message);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Docker and Compose run targets against a real daemon. Ignored by default:
|
||||
//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount
|
||||
//! (on this machine, or at the same path in a VM such as OrbStack's), and
|
||||
//! `docker` with the Compose plugin must be on `PATH`.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn host(base: &Path) -> Arc<Host> {
|
||||
Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig {
|
||||
RunConfig {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
kind: "python".into(),
|
||||
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
|
||||
args: args.iter().map(|a| a.to_string()).collect(),
|
||||
cwd: None,
|
||||
env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
type Output = Arc<Mutex<String>>;
|
||||
|
||||
async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) {
|
||||
let out: Output = Arc::default();
|
||||
let sink = out.clone();
|
||||
let info = ops::run::start(
|
||||
host,
|
||||
"main",
|
||||
config,
|
||||
Arc::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
sink.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(info.id, out)
|
||||
}
|
||||
|
||||
async fn finished(out: &Output, secs: u64) -> String {
|
||||
for _ in 0..secs * 10 {
|
||||
if out.lock().contains("Process finished") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
out.lock().clone()
|
||||
}
|
||||
|
||||
fn containers(label: &str) -> String {
|
||||
let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"]
|
||||
async fn runs_in_docker_and_compose_containers() {
|
||||
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
let name = root.file_name().unwrap().to_string_lossy().into_owned();
|
||||
std::fs::write(
|
||||
root.join("main.py"),
|
||||
"import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\
|
||||
open('out.txt', 'w').write('ok')\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("compose.yaml"),
|
||||
"services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n",
|
||||
)
|
||||
.unwrap();
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let host = host(state.path());
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let targets = serde_json::json!([
|
||||
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" },
|
||||
{ "kind": "compose", "id": "app", "name": "Compose", "service": "app" },
|
||||
]);
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"runTargets",
|
||||
&targets.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await;
|
||||
let out = finished(&out, 120).await;
|
||||
assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}");
|
||||
assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
|
||||
let written = root.join("out.txt");
|
||||
assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok");
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap());
|
||||
assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner");
|
||||
}
|
||||
std::fs::remove_file(&written).unwrap();
|
||||
assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends");
|
||||
|
||||
let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await;
|
||||
let out = finished(&out, 120).await;
|
||||
assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
|
||||
std::fs::remove_file(&written).unwrap();
|
||||
|
||||
// Stop: `docker stop` ends the container; `--rm` removes it.
|
||||
let (id, out) =
|
||||
start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"]))
|
||||
.await;
|
||||
for _ in 0..600 {
|
||||
if out.lock().contains("up\n") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock());
|
||||
ops::run::stop(&host, "main", id).unwrap();
|
||||
let out = finished(&out, 30).await;
|
||||
assert!(out.contains(" stop openidea-sleep-"), "{out}");
|
||||
for _ in 0..100 {
|
||||
if containers("label=openidea.run=sleep").is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
|
||||
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn links_are_validated_persisted_and_unlinked_without_removing_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("workspace");
|
||||
for dir in [root.join("a"), root.join("b"), base.join("external")] {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("settings.gradle"), "rootProject.name = 'same-name'").unwrap();
|
||||
}
|
||||
let config = base.join("config");
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config.clone(),
|
||||
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(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_project("main", &root).unwrap();
|
||||
assert!(ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.is_err());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec!["b/settings.gradle".into()]).await.unwrap();
|
||||
assert_eq!(model.gradle.iter().map(|g| g.dir.as_str()).collect::<Vec<_>>(), ["a", "b"]);
|
||||
let saved =
|
||||
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.gradle.linked_projects;
|
||||
assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
|
||||
assert!(ops::build::gradle_link_projects(&host, "main", vec!["missing".into()]).await.is_err());
|
||||
assert_eq!(saved(), Some(vec!["a".into(), "b".into()]));
|
||||
let outside = base.join("external").to_string_lossy().into_owned();
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec![outside.clone()]).await.unwrap();
|
||||
assert_eq!(model.gradle.len(), 3);
|
||||
let model = ops::build::gradle_unlink_projects(&host, "main", vec!["a".into(), "b".into(), outside]).await.unwrap();
|
||||
assert!(model.gradle.is_empty());
|
||||
assert_eq!(saved(), Some(vec![]));
|
||||
for dir in [root.join("a"), root.join("b"), base.join("external")] {
|
||||
assert!(dir.join("settings.gradle").is_file());
|
||||
}
|
||||
let model = ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.unwrap();
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
assert_eq!(model.gradle[0].dir, "b");
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Opening the project a window is already opening reuses it: racing opens (a double
|
||||
//! click, a launch path and a restore) must not start a second index and language servers.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
#[test]
|
||||
fn racing_opens_of_one_window_share_one_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
|
||||
let start = Arc::new(Barrier::new(4));
|
||||
let opens: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let (host, root, start) = (host.clone(), root.clone(), start.clone());
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
host.open_project("main", &root).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
|
||||
|
||||
let current = host.project("main").unwrap();
|
||||
for project in &projects {
|
||||
assert!(Arc::ptr_eq(project, ¤t), "every open returns the window's one project");
|
||||
}
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Generic document-indexer contract through a minimal real WASM fixture.
|
||||
use ide_core::{Pos, Range, SymbolKind};
|
||||
use ide_host::plugins::navigation::Snapshot;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_language::SymbolTag;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
host: PluginHost,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
host.set_project_policy(Arc::new(|_| true));
|
||||
host.install_from_dir(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
|
||||
&Consent::all(),
|
||||
)
|
||||
.unwrap();
|
||||
Self { _tmp: tmp, root, host }
|
||||
}
|
||||
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
|
||||
let path = self.root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
self.host
|
||||
.index_navigation(
|
||||
&self.root,
|
||||
Snapshot { path: &path, version, text, symbols, scope },
|
||||
Invocation { workspace_root: Some(self.root.clone()), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
fn method() -> SymbolTag {
|
||||
SymbolTag {
|
||||
name: "item".into(),
|
||||
kind: SymbolKind::Method,
|
||||
container: None,
|
||||
range: Range::new(Pos::new(0, 4), Pos::new(0, 8)),
|
||||
}
|
||||
}
|
||||
const SOURCE: &str = "def source:item\nref mapping:item\n";
|
||||
const MAPPING: &str = "def mapping:item\nref source:item\n";
|
||||
fn reference_pos() -> Pos {
|
||||
Pos::new(1, 5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional_navigation_versions_and_incremental_deletion() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets[0].path.0, source);
|
||||
assert_eq!(result.range.end.col - result.range.start.col, 11);
|
||||
let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(reverse.targets[0].path.0, mapping);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none());
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none());
|
||||
f.host.invalidate_navigation(&f.root, Some(&[source]));
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_candidates_reload_disable_and_project_isolation() {
|
||||
let f = Fixture::new();
|
||||
let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a");
|
||||
f.index("b/source.idx", SOURCE, &[method()], Some(1), "b");
|
||||
let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets.len(), 1);
|
||||
assert_eq!(result.targets[0].path.0, first);
|
||||
let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c");
|
||||
assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2);
|
||||
assert!(
|
||||
f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none()
|
||||
);
|
||||
f.host.set_enabled("test.indexer", false).unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
f.host.set_enabled("test.indexer", true).unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
f.host.reload("test.indexer").unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmed_clicks_never_read_or_parse_files() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
|
||||
// Files become unreadable; the watcher invalidates separately. Resolution
|
||||
// must remain purely in-memory, with no disk reads or component execution.
|
||||
std::fs::remove_file(&source).unwrap();
|
||||
std::fs::remove_file(&mapping).unwrap();
|
||||
let calls = f.host.info_of("test.indexer").unwrap().stats.calls;
|
||||
for _ in 0..10_000 {
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm");
|
||||
f.host.invalidate_navigation(&f.root, None);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_and_paths_are_checked_before_indexing() {
|
||||
let f = Fixture::new();
|
||||
let external = tempfile::NamedTempFile::new().unwrap();
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" },
|
||||
Invocation::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.host.set_project_policy(Arc::new(|_| false));
|
||||
assert!(f.host.navigation_extensions(&f.root).is_empty());
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" },
|
||||
Invocation::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
|
||||
let f = Fixture::new();
|
||||
let symbol = method();
|
||||
let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app");
|
||||
f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app");
|
||||
let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets[0].path.0, definition);
|
||||
assert_eq!(result.targets[0].range, method().range);
|
||||
f.index("unicode.idx", "def 😀\n", &[], Some(1), "app");
|
||||
let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap();
|
||||
assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
|
||||
for client in ["one", "two"] {
|
||||
let mut symbol = method();
|
||||
if client == "two" {
|
||||
symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9));
|
||||
}
|
||||
for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] {
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
|
||||
Invocation { client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0]
|
||||
.range
|
||||
.start
|
||||
.col,
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0]
|
||||
.range
|
||||
.start
|
||||
.col,
|
||||
5
|
||||
);
|
||||
f.host.invalidate_client_navigation(&f.root, Some("one"), None);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none());
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_and_disable_follow_graph_state() {
|
||||
let f = Fixture::new();
|
||||
let tags = vec![];
|
||||
let source = f.index("source.idx", SOURCE, &tags, Some(1), "app");
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
|
||||
let source_markers = entries.iter().find(|e| e.path == source).unwrap();
|
||||
assert_eq!(source_markers.markers.len(), 1);
|
||||
assert_eq!(source_markers.markers[0].targets[0].path.0, mapping);
|
||||
let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap();
|
||||
assert_eq!(mapping_markers.markers.len(), 1);
|
||||
assert_eq!(mapping_markers.markers[0].targets[0].path.0, source);
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty());
|
||||
f.host.invalidate_navigation(&f.root, Some(&[mapping]));
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
|
||||
f.host.set_enabled("test.indexer", false).unwrap();
|
||||
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ranges_and_oversized_documents_never_publish() {
|
||||
let f = Fixture::new();
|
||||
let path = f.root.join("invalid.idx");
|
||||
std::fs::write(&path, "invalid key\n").unwrap();
|
||||
for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] {
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" },
|
||||
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! `plugins/build.sh <dir>` after changing their source or `wit/`.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
@@ -214,7 +215,7 @@ fn fixture(limits: Limits) -> Fixture {
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
|
||||
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
|
||||
let project = project.canonicalize().unwrap();
|
||||
let project = dunce::canonicalize(project).unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
@@ -359,7 +360,7 @@ fn read_file_is_confined_to_the_project() {
|
||||
let inv = Invocation { client: None, workspace_root: None, editor: None };
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
assert!(err.contains("no project is open"), "{err}");
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
@@ -1740,7 +1741,7 @@ fn official_checkstyle_checks_on_save() {
|
||||
let java = f.project.join("src/A.java");
|
||||
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
|
||||
let saved = |f: &Fixture| {
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
};
|
||||
saved(&f);
|
||||
@@ -1793,7 +1794,7 @@ fn official_license_header_reports_fixes_and_scans() {
|
||||
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
|
||||
};
|
||||
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(20)));
|
||||
let d = diagnostics(&a).expect("published on open");
|
||||
@@ -1818,7 +1819,7 @@ fn official_license_header_reports_fixes_and_scans() {
|
||||
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
|
||||
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
|
||||
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(20)));
|
||||
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
|
||||
@@ -2077,6 +2078,62 @@ fn views_open_project_files_at_a_line() {
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcs_remotes_clipboard_and_open_url() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
|
||||
// Not a git repository yet: `vcs-remotes` returns an empty list.
|
||||
assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
|
||||
|
||||
git(&f.project, &["init", "-b", "main"]);
|
||||
git(&f.project, &["remote", "add", "origin", "[email protected]:l0ng-ai/openidea.git"]);
|
||||
git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
|
||||
assert_eq!(
|
||||
f.run("jobs", "remotes", None).unwrap().as_deref(),
|
||||
Some("[email protected]:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
|
||||
);
|
||||
|
||||
f.host.set_capability("jobs", "vcs.read", false).unwrap();
|
||||
assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
|
||||
|
||||
// `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
|
||||
f.take_events();
|
||||
f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
|
||||
let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
|
||||
|
||||
f.host.set_capability("jobs", "clipboard", false).unwrap();
|
||||
f.take_events();
|
||||
assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
|
||||
|
||||
// `ui.open-url` accepts http(s) URLs and rejects other schemes.
|
||||
f.take_events();
|
||||
f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
|
||||
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
opened,
|
||||
Some(
|
||||
(Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
|
||||
)
|
||||
);
|
||||
|
||||
for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
|
||||
f.take_events();
|
||||
let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
|
||||
assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -223,7 +223,8 @@ async fn kotlin_gradle() {
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let gradle = model.gradle.expect("a Gradle model");
|
||||
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
|
||||
let gradle = &model.gradle[0];
|
||||
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
@@ -231,8 +232,9 @@ async fn kotlin_gradle() {
|
||||
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
|
||||
let p = open(fixture).await;
|
||||
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
|
||||
let model =
|
||||
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
|
||||
let model = ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], None, progress)
|
||||
.await
|
||||
.unwrap();
|
||||
// The cached sync is what the next open shows.
|
||||
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(reloaded, model);
|
||||
@@ -253,7 +255,9 @@ async fn maven_sync() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
|
||||
let model = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await;
|
||||
assert_eq!(model.gradle.len(), 1, "one independent Gradle build");
|
||||
let gradle = &model.gradle[0];
|
||||
assert_eq!(gradle.sync_error, None);
|
||||
assert!(gradle.synced_at.is_some());
|
||||
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
|
||||
|
||||
@@ -249,6 +249,7 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
None,
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -35,6 +35,8 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
|
||||
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(20)));
|
||||
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
|
||||
let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
|
||||
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
|
||||
}
|
||||
|
||||
@@ -189,6 +189,43 @@ impl ProjectIndex {
|
||||
out
|
||||
}
|
||||
|
||||
/// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or
|
||||
/// not-yet-indexed contents fall back to the caller's parser.
|
||||
pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option<Vec<ide_language::SymbolTag>> {
|
||||
let hash = blake3::hash(text.as_bytes());
|
||||
let data = self.shared.data.read();
|
||||
let file = data.get(path)?;
|
||||
if &file.hash != hash.as_bytes() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
file.symbols
|
||||
.iter()
|
||||
.map(|s| ide_language::SymbolTag {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_ref().map(ToString::to_string),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bounded file inventory for background plugin indexers; honors the same
|
||||
/// project/exclude scope as search and never walks the filesystem.
|
||||
pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec<PathBuf> {
|
||||
self.shared
|
||||
.data
|
||||
.read()
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)))
|
||||
.take(limit)
|
||||
.map(|f| f.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
|
||||
/// matches; `/` in the query matches path segments; recently opened files boost.
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
@@ -197,6 +234,28 @@ impl ProjectIndex {
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
}
|
||||
|
||||
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
|
||||
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
|
||||
/// indexed yet (first scan without a cache).
|
||||
pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
let d = self.shared.data.read();
|
||||
if d.file_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for file in d.files.iter().flatten().filter(|f| !f.ignored) {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
if let Ok(rel) = file.path.strip_prefix(dir)
|
||||
&& matches(rel)
|
||||
{
|
||||
out.push(file.path.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_dirs(&self.shared.data.read(), query, limit)
|
||||
|
||||
@@ -632,3 +632,23 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("", 100).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
|
||||
let java = "package demo; interface UserMapper { void find(); }";
|
||||
let f = Fixture::new(&[
|
||||
("src/UserMapper.java", java),
|
||||
("src/mapper.xml", "<mapper/>"),
|
||||
("target/Generated.java", "class Generated {}"),
|
||||
("other.txt", "ignored"),
|
||||
]);
|
||||
let index = f.open();
|
||||
let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100);
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths.iter().all(|p| p.starts_with(f.root.join("src"))));
|
||||
assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1);
|
||||
let path = f.root.join("src/UserMapper.java");
|
||||
let symbols = index.symbols_for_text(&path, java).unwrap();
|
||||
assert!(symbols.iter().any(|s| s.name == "find"));
|
||||
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
|
||||
}
|
||||
Loaded 100 of 787 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user