Compare commits

..
Author SHA1 Message Date
l0ng-ai 3dc85d4ffe fix(plugins): list the marketplace and install plugins from the welcome screen
The Plugins page on the welcome screen showed "[object Object]" and "There
is no online plugin repository" (even with a marketplace configured), and
Install did nothing.

- Marketplace and package commands (index, download, unpack, install
  preview, theme import) only touch the IDE's own state and are allowed
  without a trusted project.
- Installing, uninstalling, enabling and granting plugins are allowed
  where no project is open; an untrusted project still blocks them.
- Errors rejected as plain backend error objects show their message, and
  a marketplace that couldn't be read shows the error instead of the
  "no online repository" note.
2026-11-04 19:55:50 +08:00
837 changed files with 4833 additions and 66848 deletions

No files matched your search

+2 -3
View File
@@ -1,4 +1,4 @@
<!-- If this resolves an issue: Closes #<n> -->
Closes #
## What and why
@@ -9,8 +9,7 @@
<!-- Required. Commands you actually ran and their results; what else this could
affect and how you checked it still works.
Visible UI changes: before/after screenshots (not for removed UI or copy-only
changes). Multi-step interactions: a recording.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
+15 -77
View File
@@ -7,9 +7,6 @@ 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.
@@ -18,64 +15,20 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Line tables keep test backtraces readable; full debuginfo only adds build time and cache size.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
# Which parts a pull request touches, so jobs it can't affect skip and leave the
# shared runners to others. Pushes and manual runs always run everything.
changes:
name: Changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: read
outputs:
rust: ${{ steps.classify.outputs.rust }}
frontend: ${{ steps.classify.outputs.frontend }}
steps:
- id: classify
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
echo "rust=true" >> "$GITHUB_OUTPUT"
echo "frontend=true" >> "$GITHUB_OUTPUT"
exit 0
fi
gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
--jq '.[] | .filename, (.previous_filename // empty)' > files.txt
cat files.txt
docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
# Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
# A job runs when any changed file falls outside the paths that can't affect it.
rust=false
if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
frontend=false
if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
echo "rust=$rust frontend=$frontend"
echo "rust=$rust" >> "$GITHUB_OUTPUT"
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
rust:
name: Rust (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { components: "clippy, rustfmt" }
@@ -107,12 +60,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
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: x86_64-unknown-linux-musl }
- uses: Swatinem/rust-cache@v2
@@ -125,14 +75,11 @@ 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
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-wasip2, components: "clippy, rustfmt" }
- uses: Swatinem/rust-cache@v2
@@ -153,9 +100,9 @@ jobs:
cargo test --locked --manifest-path "$dir/Cargo.toml"
echo "::endgroup::"
done
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22 }
# Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test.
- name: Web pages typecheck and test
@@ -177,27 +124,21 @@ 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
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-deny
- run: cargo deny check
frontend:
name: Frontend
needs: changes
if: needs.changes.outputs.frontend == 'true'
timeout-minutes: 30
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
@@ -209,23 +150,20 @@ jobs:
e2e:
name: E2E
needs: changes
if: needs.changes.outputs.frontend == 'true'
timeout-minutes: 30
runs-on: ubuntu-24.04
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm e2e
env:
HOME: /root
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
-125
View File
@@ -1,125 +0,0 @@
name: Language smoke
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
# Step Over, Resume): smoke/debug.test.ts.
on:
push:
branches: [main]
paths: &paths
- "crates/**"
- "languages/**"
- "fixtures/**"
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths: *paths
workflow_dispatch:
concurrency:
group: language-smoke-${{ github.ref }}
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
VTSLS: 0.3.0
TYPESCRIPT: 6.0.3
BASEDPYRIGHT: 1.40.2
RUFF: 0.17.3
GOPLS: v0.24.0
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
jobs:
smoke:
name: Language smoke (${{ matrix.language }})
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with: { components: "rust-analyzer, rustfmt" }
# One build cache for every language; main saves it.
- uses: Swatinem/rust-cache@v2
with:
shared-key: language-smoke
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
- if: matrix.language == 'python'
run: pip install "basedpyright==$BASEDPYRIGHT" "ruff==$RUFF"
- if: matrix.language == 'go'
uses: actions/setup-go@v5
with: { go-version: "1.27", cache: false }
- if: matrix.language == 'go'
run: go install "golang.org/x/tools/gopls@$GOPLS"
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "21" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
curl -fsSL "https://download.eclipse.org/jdtls/milestones/${JDTLS%-*}/jdt-language-server-$JDTLS.tar.gz" \
| tar xz -C "$RUNNER_TEMP/jdtls"
echo "$RUNNER_TEMP/jdtls/bin" >> "$GITHUB_PATH"
# The Kotlin fixture has no Gradle wrapper: the server imports it with the Gradle on PATH.
- if: matrix.language == 'kotlin'
uses: gradle/actions/setup-gradle@v4
with: { gradle-version: "${{ env.GRADLE }}" }
- if: matrix.language == 'kotlin'
run: |
mkdir -p "$RUNNER_TEMP/kotlin-lsp" "$RUNNER_TEMP/bin"
curl -fsSL "https://download.jetbrains.com/language-server/kotlin-server/$KOTLIN_LSP/kotlin-server-$KOTLIN_LSP.tar.gz" \
| tar xz -C "$RUNNER_TEMP/kotlin-lsp" --strip-components=1
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
- if: matrix.language == 'kotlin'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
- if: matrix.language == 'python'
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
+10 -1
View File
@@ -18,15 +18,24 @@ jobs:
if: github.event.pull_request.user.type != 'Bot' && !github.event.pull_request.draft
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const LABEL = 'needs-info';
const MARKER = '<!-- pr-check -->';
// Docs, CI and pure refactors may skip the linked issue.
const NO_ISSUE_TYPES = /^(docs|ci|build|chore|refactor|style|test)(\(.+\))?!?:/;
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
const problems = [];
const linksIssue =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s*:?\s+(#\d+|https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+)/i.test(body);
if (!linksIssue && !NO_ISSUE_TYPES.test(pr.title)) {
problems.push('Link the issue this PR resolves, e.g. `Closes #123`.');
}
const verified = body.match(/^##+\s*How it was verified\s*$([\s\S]*?)(?=^##+\s|(?![\s\S]))/im);
if (!verified || !verified[1].trim()) {
problems.push('Fill in **How it was verified**: the commands you ran and their results. UI changes need before/after screenshots.');
+11 -21
View File
@@ -28,7 +28,7 @@ jobs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- name: Tag matches app version
run: |
@@ -37,16 +37,6 @@ 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
@@ -62,7 +52,7 @@ jobs:
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const tag = process.env.TAG;
@@ -109,11 +99,11 @@ jobs:
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
@@ -183,7 +173,7 @@ jobs:
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
@@ -230,7 +220,7 @@ jobs:
echo "::warning::PLUGIN_SIGNING_KEY is not set: the official plugins are not published"
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v5
- uses: actions/checkout@v4
if: steps.key.outputs.skip != 'true'
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
@@ -239,10 +229,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: steps.key.outputs.skip != 'true'
with: { key: release-plugins }
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v4
if: steps.key.outputs.skip != 'true'
with: { version: 11 }
- uses: actions/setup-node@v5
- uses: actions/setup-node@v4
if: steps.key.outputs.skip != 'true'
with: { node-version: 22 }
# Builds the web pages; fails when a checked-in component isn't built from these sources.
@@ -287,7 +277,7 @@ jobs:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
@@ -347,7 +337,7 @@ jobs:
needs: [create-release, build, server, plugins, updater-manifest]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
- uses: actions/github-script@v7
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
+8 -19
View File
@@ -18,12 +18,9 @@ Visual style: dark, low-contrast greys, one cool accent
- `src/ui/` — design-system primitives. `src/features/<name>/` — one feature each,
exporting `activate()` registered in `src/features/index.ts`.
- `languages/<id>.toml` — language definitions (detection, comments, LSP, DAP).
- `crates/ide-host` — the `openidea-server` binary for remote development and the
`openidea-plugin` CLI (plugin starter templates live in its `templates.rs`).
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins.
Plugin docs: `plugins/README.md`, `docs/plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `crates/ide-host` — the `openidea-server` binary for remote development.
- `wit/` — plugin interface definitions; `plugins/` — sample and bundled plugins;
`templates/` — plugin starter templates.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
@@ -49,10 +46,9 @@ Visual style: dark, low-contrast greys, one cool accent
## Issues & PRs
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and, when the change alters what users
see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one issue per PR with
`Closes #<n>`, reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
Unclear product decisions → `needs-decision` and stop.
## Skills
@@ -68,13 +64,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
this worktree's running MCP bridges and actual ports.
- `pnpm build` — typecheck + bundle frontend.
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
@@ -122,8 +111,8 @@ Several worktrees can be developed and run side by side.
default MCP session belongs to this worktree.
- Keep each worktree's Cargo output directory separate; do not point `CARGO_TARGET_DIR` or
`target` symlinks at a shared directory.
- Each worktree's first build is cold (several minutes) and `cargo build` output takes
around 9 GB; tests, clippy and incremental builds add more. Avoid running several heavy builds on one machine at the same time.
- Each worktree's first build is cold (around 5 minutes) and its Cargo output takes
15–30 GB. Avoid running several heavy builds on one machine at the same time.
- Stop only processes started for the current worktree/session. Never use broad
process-name kills such as `pkill openidea`.
- When done: stop that worktree's app and dev session, delete its build output, then
+6 -5
View File
@@ -16,7 +16,8 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
## Pull requests
- One problem per PR. If it resolves an issue, link it with `Closes #123`.
- One issue per PR, linked with `Closes #123`. Feature PRs need a `planned` issue.
Docs, CI and pure refactors may skip the issue.
- Reproduce the problem first, or write a test that fails before the fix.
- Fix the cause, and check whether the same cause breaks anything nearby.
- Keep the diff focused. Unrelated refactors go in their own PR.
@@ -25,9 +26,8 @@ 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. 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.
and what else the change could affect. UI changes need before/after screenshots;
multi-step interactions need a recording.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
@@ -36,4 +36,5 @@ and the plugin API. Community plugins live in their authors' repositories and
are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins);
see [`plugins/README.md`](plugins/README.md) § 8.1.
A check on each PR adds `needs-info` when the verification section is missing, and removes it once it's filled in.
A check on each PR adds `needs-info` when the issue link or the verification
section is missing, and removes it once they're filled in.
Generated
+71 -362
View File
@@ -123,17 +123,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
@@ -167,27 +156,6 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.5",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus",
]
[[package]]
name = "ast-grep-core"
version = "0.40.1"
@@ -1496,7 +1464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
@@ -1632,7 +1600,6 @@ dependencies = [
"lock_api",
"once_cell",
"parking_lot_core",
"serde",
]
[[package]]
@@ -1828,15 +1795,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "dlib"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -2205,6 +2163,15 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -3001,7 +2968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -4071,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4094,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4106,20 +4073,9 @@ dependencies = [
"tree-sitter",
]
[[package]]
name = "ide-android"
version = "0.1.37"
dependencies = [
"ide-core",
"serde",
"specta",
"tempfile",
"which",
]
[[package]]
name = "ide-build"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4134,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4143,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4160,11 +4116,11 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
"quick-xml 0.42.0",
"quick-xml",
"serde",
"serde_json",
"specta",
@@ -4174,7 +4130,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4192,7 +4148,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4218,7 +4174,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"serde",
@@ -4231,7 +4187,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4251,7 +4207,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4263,7 +4219,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4276,7 +4232,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"criterion",
@@ -4316,7 +4272,7 @@ dependencies = [
"ide-workspace",
"ignore",
"parking_lot",
"quick-xml 0.42.0",
"quick-xml",
"regex-syntax",
"reqwest",
"rustix",
@@ -4337,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4355,7 +4311,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
@@ -4377,7 +4333,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"encoding_rs",
"futures",
@@ -4394,7 +4350,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"blake3",
"criterion",
@@ -4450,7 +4406,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dunce",
"futures",
@@ -4476,7 +4432,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"futures",
"ide-core",
@@ -4499,7 +4455,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"blake3",
"ide-core",
@@ -4515,12 +4471,11 @@ dependencies = [
"tracing",
"wasmtime",
"wasmtime-wasi",
"wat",
]
[[package]]
name = "ide-profiler"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-run",
@@ -4533,7 +4488,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"globset",
"ide-core",
@@ -4550,7 +4505,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dunce",
"encoding_rs",
@@ -4559,7 +4514,7 @@ dependencies = [
"libc",
"parking_lot",
"portable-pty",
"quick-xml 0.42.0",
"quick-xml",
"serde",
"serde_json",
"specta",
@@ -4573,7 +4528,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dunce",
"ide-core",
@@ -4592,7 +4547,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"criterion",
"globset",
@@ -4612,7 +4567,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"parking_lot",
@@ -4625,13 +4580,11 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"flate2",
"ide-core",
"keyring",
"keyring-core",
"parking_lot",
"russh",
"russh-config",
@@ -4642,12 +4595,11 @@ dependencies = [
"tempfile",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "ide-syntax"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"ide-language",
@@ -4662,7 +4614,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"libc",
@@ -4675,20 +4627,19 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"criterion",
"ide-core",
"proptest",
"ropey",
"serde",
"similar",
"specta",
]
[[package]]
name = "ide-tools"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"flate2",
"hex",
@@ -4709,16 +4660,14 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"blake3",
"dunce",
"encoding_rs",
"gix",
"ide-core",
"ignore",
"libc",
"redb",
"regex-lite",
"serde",
"serde_json",
@@ -4730,7 +4679,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"chardetng",
"dunce",
@@ -4739,7 +4688,7 @@ dependencies = [
"globset",
"ide-core",
"ignore",
"notify",
"notify-debouncer-full",
"parking_lot",
"serde",
"serde_json",
@@ -4752,7 +4701,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"ide-core",
"open",
@@ -4768,7 +4717,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"blake3",
"dunce",
@@ -4786,7 +4735,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"toml_edit 0.25.17+spec-1.1.0",
"tracing",
"uuid",
"zip",
]
@@ -5264,33 +5212,6 @@ dependencies = [
"serde",
]
[[package]]
name = "keyring"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59"
dependencies = [
"chrono",
"dashmap",
"log",
"regex",
"ron",
"serde",
"uuid",
]
[[package]]
name = "kqueue"
version = "1.2.1"
@@ -5713,6 +5634,19 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"walkdir",
]
[[package]]
name = "notify-types"
version = "2.1.0"
@@ -5741,30 +5675,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint 0.4.8",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
@@ -5777,15 +5687,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -5801,27 +5702,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint 0.4.8",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -6309,7 +6189,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.37"
version = "0.1.35"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6319,7 +6199,6 @@ dependencies = [
"gtk",
"ide-agent",
"ide-analysis",
"ide-android",
"ide-build",
"ide-clipboard",
"ide-core",
@@ -6374,12 +6253,10 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
"tempfile",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows",
"windows-sys 0.61.2",
]
@@ -6713,7 +6590,7 @@ checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
dependencies = [
"base64 0.23.1",
"indexmap 2.14.2",
"quick-xml 0.42.0",
"quick-xml",
"serde",
"time",
]
@@ -6758,12 +6635,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "poly1305"
version = "0.9.1"
@@ -7024,15 +6895,6 @@ version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
@@ -7310,18 +7172,18 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -7342,19 +7204,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ron"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
dependencies = [
"bitflags 2.13.2",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "ropey"
version = "1.6.1"
@@ -7421,7 +7270,7 @@ dependencies = [
"md5",
"ml-kem",
"module-lattice",
"num-bigint 0.5.1",
"num-bigint",
"p256",
"p384",
"p521",
@@ -7752,12 +7601,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -7808,25 +7651,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "secret-service"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
dependencies = [
"aes",
"cbc",
"futures-util",
"getrandom 0.4.3",
"hkdf",
"hybrid-array",
"num",
"once_cell",
"serde",
"sha2 0.11.1",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -8377,7 +8201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
@@ -9315,7 +9139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -9503,7 +9327,6 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -10320,12 +10143,6 @@ 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"
@@ -10846,88 +10663,6 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"scoped-tls",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.2",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.106"
@@ -11213,19 +10948,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
@@ -11752,7 +11474,6 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
@@ -11763,17 +11484,6 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
@@ -11979,7 +11689,6 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
+2 -10
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.37"
version = "0.1.35"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -76,6 +76,7 @@ ignore = "0.4"
base64 = "0.22"
globset = "0.4"
notify = "8"
notify-debouncer-full = "0.6"
trash = "5"
blake3 = "1"
dunce = "1"
@@ -105,9 +106,6 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
bb8 = "0.9"
uuid = { version = "1", features = ["v4"] }
# OS credential stores
keyring = "4.2"
keyring-core = "1"
# testing
insta = { version = "1", features = ["yaml"] }
@@ -117,14 +115,8 @@ tempfile = "3"
[profile.dev]
# Keep debug builds snappy while giving dependencies decent speed.
opt-level = 0
# Full debug info is most of target/. Line tables keep file:line in our backtraces, but a
# debugger can't show locals; re-enable with `--config profile.dev.debug=true`.
debug = "line-tables-only"
[profile.dev.package."*"]
opt-level = 2
# Dependency frames keep function names only. Re-enable one with
# `--config 'profile.dev.package.<crate>.debug=true'`.
debug = false
[profile.release]
codegen-units = 1
+1 -20
View File
@@ -14,9 +14,7 @@
&nbsp;·&nbsp;
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -47,23 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Plugins
Plugins add actions, completion, quick fixes, tool windows, custom editors,
themes, file icons and languages. They run as sandboxed WebAssembly components;
themes, icon themes and languages need no code at all.
Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+3 -12
View File
@@ -2,12 +2,12 @@
## Project Lombok
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
`plugins/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
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.
Source tag, patch commits and build instructions: [java/lombok/README.md](java/lombok/README.md).
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
## JetBrains IntelliJ Platform icons
@@ -52,15 +52,6 @@ Their original copyright headers and Apache-2.0 licensing are retained.
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform VCS log graph algorithms
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
graph layout and LinearBek merge linearization algorithms from
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
-3
View File
@@ -76,8 +76,6 @@ 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)]
@@ -334,7 +332,6 @@ fn visible(
distribution,
no_checksum,
installed,
installing: false,
})
})
.collect();
-15
View File
@@ -1,15 +0,0 @@
[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
-222
View File
@@ -1,222 +0,0 @@
//! 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());
}
}
}
-276
View File
@@ -1,276 +0,0 @@
//! 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);
}
}
-206
View File
@@ -1,206 +0,0 @@
//! 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:?}");
}
}
-65
View File
@@ -1,65 +0,0 @@
//! 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");
}
+3 -3
View File
@@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide
kind: "maven",
});
}
if let Some(g) = model.gradle_for_module(root, &module)
if let Some(g) = &model.gradle
&& 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 = vec![crate::GradleModel {
model.gradle = Some(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");
+18 -99
View File
@@ -147,9 +147,7 @@ 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 model.gradle_for_module(root, dir).is_some()
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
@@ -164,13 +162,16 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
}
/// Resolves a workspace-relative or absolute directory with native path separators.
/// `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.
fn subdir(root: &Path, rel: &str) -> PathBuf {
root.join(rel).components().collect()
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn rel_str(root: &Path, dir: &Path) -> String {
dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/")
dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default()
}
/// Directories from `file`'s folder up to `root` (inclusive).
@@ -191,14 +192,7 @@ 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 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)
let dir = ancestors_within(root, 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())
@@ -208,7 +202,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((!model.gradle.is_empty()).then_some(BuildSystem::Gradle))
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
.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")
@@ -276,16 +270,14 @@ 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>> {
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 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
}
};
let prefix = module
.and_then(|m| {
let rel = rel_str(root, m);
@@ -304,7 +296,7 @@ fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Opt
let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle);
s.args.extend(tasks);
s.env = g.env.clone();
vec![s]
Ok(vec![s])
}
fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
@@ -819,79 +811,6 @@ 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);
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
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
-129
View File
@@ -435,17 +435,6 @@ 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());
@@ -466,65 +455,6 @@ pub fn load_build(root: &Path, dir: &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> {
@@ -686,65 +616,6 @@ 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#"
+5 -16
View File
@@ -126,7 +126,6 @@ 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,
@@ -138,6 +137,7 @@ 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,12 +226,8 @@ 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(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err()
);
assert!(
gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err()
);
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
}
#[cfg(unix)]
@@ -250,15 +246,8 @@ runtimeClasspath - Runtime classpath of source set 'main'.
offline: true,
..Default::default()
};
let tree = gradle_dependencies(
root,
&gradle::load(root).unwrap(),
&options,
":app",
"testRuntimeClasspath",
&CancellationToken::new(),
)
.unwrap();
let tree =
gradle_dependencies(root, &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(),
+16 -91
View File
@@ -20,9 +20,7 @@ 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_gradle_model, sync_maven,
};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -40,31 +38,13 @@ pub enum BuildTool {
#[serde(rename_all = "camelCase")]
pub struct BuildModel {
pub maven: Option<MavenModel>,
#[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 })
})
}
pub gradle: Option<GradleModel>,
}
/// 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(),
&ide_core::settings::GradleSettings::default(),
)
load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default())
}
/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added.
@@ -73,66 +53,31 @@ 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::has_projects(root, settings).then(|| {
let maven = maven::is_maven_project(root).then(|| {
let profiles = sync::maven_profiles(cache_dir);
let repo = maven_repo.or_else(maven::default_local_repo);
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
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_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();
let gradle = gradle::load(root).map(|mut g| {
sync::apply_gradle_cache(root, cache_dir, &mut g);
g
});
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,
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))
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()))
}
/// Source, resource and build folders of the last Gradle sync, absolute.
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)
pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> {
gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir))
}
/// A library the project depends on, as `groupId:artifactId:version`.
@@ -176,7 +121,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
});
}
}
for g in &model.gradle {
if let Some(g) = &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)| {
@@ -202,26 +147,6 @@ 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();
@@ -233,7 +158,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[0].projects[0].path, ":");
assert_eq!(m.gradle.unwrap().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")));
}
@@ -280,7 +205,7 @@ mod tests {
],
..Default::default()
};
let gradle = gradle::load(root).into_iter().collect();
let gradle = gradle::load(root);
let model = BuildModel { maven: Some(maven), gradle };
let names: Vec<String> = dependency_coordinates(root, &model)
.into_iter()
+7 -157
View File
@@ -472,41 +472,6 @@ 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> {
@@ -1100,9 +1065,10 @@ pub fn load(root: &Path) -> MavenModel {
load_with_repo(root, default_local_repo())
}
/// [`load`] with the POMs `settings` link (Add Maven Projects).
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
/// [`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)
}
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
@@ -1124,53 +1090,11 @@ 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();
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();
// (pom, aggregated)
let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect();
while let Some((path, aggregated)) = stack.pop() {
if model.projects.len() >= MAX_PROJECTS {
break;
@@ -1292,7 +1216,7 @@ impl UnlinkedProjects {
if settings.unlinked_projects.is_empty() {
return Self::default();
}
Self::from_model(&load_linked(root, settings), settings)
Self::from_model(&load(root), settings)
}
pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self {
@@ -1571,37 +1495,6 @@ 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();
@@ -1840,49 +1733,6 @@ 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();
+8 -179
View File
@@ -283,12 +283,7 @@ pub fn sync_maven(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
let mut model = maven::load_with_linked(
root,
maven::default_local_repo(),
&options.profiles,
&options.maven_settings.linked_projects,
);
let mut model = maven::load_with_profiles(root, &options.profiles);
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
std::fs::create_dir_all(cache_dir)?;
let mut cache = MavenCache {
@@ -320,10 +315,7 @@ pub fn sync_maven(
for (n, top) in model.projects.iter().filter(|p| !p.aggregated).enumerate() {
let effective = cache_dir.join(format!("effective-pom-{n}.xml"));
let _ = std::fs::remove_file(&effective);
// `-T1` keeps mvnd serial: its default parallel build interleaves the
// `dependency:tree` output of concurrent modules, which the sync parses
// as one stream. mvn is serial anyway, so this only pins the daemon.
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into()];
args.extend(options.maven_options.iter().cloned());
if options.offline {
args.push("-o".into());
@@ -378,25 +370,8 @@ pub fn sync_gradle(
cancel: &CancellationToken,
progress: &dyn Fn(SyncProgress),
) -> IdeResult<()> {
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 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 init_script = cache_dir.join("openidea-model.gradle");
std::fs::write(&init_script, MODEL_INIT_SCRIPT)?;
let mut args: Vec<String> = vec![
@@ -411,24 +386,14 @@ pub fn sync_gradle_model(
args.push("--offline".into());
}
progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None });
let out = match run(
let out = 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);
@@ -439,128 +404,13 @@ pub fn sync_gradle_model(
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() {
@@ -748,7 +598,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, &Default::default()), [billing]);
assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [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();
@@ -830,25 +680,4 @@ mod tests {
assert_eq!(error_summary(text), "Failed to execute goal");
assert_eq!(error_summary("one\nlast line\n\n"), "last line");
}
#[cfg(unix)]
#[test]
fn maven_sync_runs_the_daemon_command_serially() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let launcher = tmp.path().join("fake-mvnd");
std::fs::write(&launcher, "#!/bin/sh\nprintf '%s\\n' \"$@\" > mvnd-args\n").unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let options =
SyncOptions { maven_command: Some(launcher.to_string_lossy().into_owned()), ..Default::default() };
sync_maven(&root, &tmp.path().join("cache"), &options, &CancellationToken::new(), &|_| {}).unwrap();
let args = std::fs::read_to_string(root.join("mvnd-args")).unwrap();
// `-T1` pins the daemon serial so the dependency trees stay parseable.
assert!(args.contains("-T1"), "args: {args}");
assert!(args.contains("help:effective-pom"), "args: {args}");
}
}
+5 -40
View File
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
});
}
}
for gradle in &model.gradle {
if let Some(gradle) = &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: gradle_artifact_id(gradle, &p.path),
id: format!("gradle:{}", p.path),
name: p.name.clone(),
module: p.dir.clone(),
tool: "gradle".into(),
@@ -42,14 +42,6 @@ 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>,
@@ -158,14 +150,9 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
maven_project: Some((probe, output)),
});
}
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)) {
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) {
return Err(IdeError::invalid("Choose a Gradle WAR module"));
}
let script = cache.join("war.init.gradle");
@@ -199,28 +186,6 @@ 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() {
+1 -1
View File
@@ -22,4 +22,4 @@ tempfile.workspace = true
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
-23
View File
@@ -2,18 +2,6 @@
//! treats as nonexistent — hidden from the project view and navigation, never
//! indexed or searched, never offered as unversioned changes.
use std::path::Path;
/// Whether `dir` lies in a Git (or jj) working tree: it or an ancestor holds `.git` / `.jj`.
///
/// Walkers pass it to `ignore::WalkBuilder::require_git`: inside a repository, `.gitignore`
/// rules then stop at a nested repository's root as in git, so an outer repository's
/// `.gitignore` does not hide a project that is its own repository. A folder outside any
/// repository still honours its `.gitignore`.
pub fn inside_git_repo(dir: &Path) -> bool {
dir.ancestors().any(|d| d.join(".git").exists() || d.join(".jj").exists())
}
/// IDEA's default list, plus `.jj` and the Windows shell files.
pub const DEFAULT_IGNORED_NAMES: &[&str] = &[
"*.pyc",
@@ -115,15 +103,4 @@ mod tests {
assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc"));
assert!(!m.is_ignored_path("src/main.rs"));
}
#[test]
fn finds_the_repository_at_or_above_a_directory() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("a/b");
std::fs::create_dir_all(&sub).unwrap();
assert!(!inside_git_repo(&sub));
std::fs::create_dir(dir.path().join("a/.git")).unwrap();
assert!(inside_git_repo(&sub));
assert!(inside_git_repo(&dir.path().join("a")));
}
}
@@ -36,23 +36,6 @@ impl Default for InspectionSettings {
}
}
impl InspectionSettings {
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
let custom = |level: &str, key: &str| match level {
"ide" => self.ide_profiles.get(key),
"project" => self.project_profiles.get(key),
_ => None,
};
match self.profile.as_str() {
"project" => &self.project,
id => {
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
}
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -128,20 +111,4 @@ mod tests {
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
#[test]
fn active_profile_falls_back_to_default() {
let off = |id: &str| {
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
};
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
assert!(s.active().contains_key("a"));
s.profile = "project".into();
assert!(s.active().contains_key("b"));
s.profile = "ide:strict".into();
assert!(s.active().contains_key("c"));
s.profile = "project:gone".into();
assert!(s.active().contains_key("a"));
}
}
-1
View File
@@ -24,7 +24,6 @@ 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};
+15 -101
View File
@@ -43,21 +43,19 @@ pub fn own_group(cmd: &mut Command) -> &mut Command {
/// Kills the child and everything it started, then reaps it.
///
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id, then a
/// wait for what the child started to be gone.
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
pub fn kill_tree(child: &mut Child) {
#[cfg(unix)]
signal_group(child, libc::SIGKILL);
#[cfg(windows)]
taskkill_tree_of(child.id());
taskkill_tree(child);
let _ = child.kill();
let _ = child.wait();
}
/// [`kill_tree`] for a child known by its process id (a `tokio` child): `SIGKILL` to its
/// process group (spawn it with [`own_group`]) or `taskkill /T /F` and a wait for what the
/// child started to be gone. Call it before the child is reaped, so the id still names it.
/// The child itself is left to its owner to kill and reap.
/// process group (spawn it with [`own_group`]) or `taskkill /T /F`. Call it before the child
/// is reaped, so the id still names it. The child itself is left to its owner to kill and reap.
pub fn kill_tree_of(pid: u32) {
#[cfg(unix)]
if let Ok(pid) = libc::pid_t::try_from(pid) {
@@ -67,7 +65,15 @@ pub fn kill_tree_of(pid: u32) {
}
}
#[cfg(windows)]
taskkill_tree_of(pid);
{
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
}
#[cfg(not(any(unix, windows)))]
let _ = pid;
}
@@ -100,107 +106,15 @@ fn signal_group(child: &Child, signal: libc::c_int) {
}
}
/// How long killing a tree waits for the processes the child started to be gone.
#[cfg(windows)]
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
/// started is gone: termination is asynchronous, and until a process is gone the files it
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
#[cfg(windows)]
fn taskkill_tree_of(pid: u32) {
let started = windows_tree::descendants(pid);
fn taskkill_tree(child: &Child) {
let mut taskkill = Command::new("taskkill");
prepare(&mut taskkill)
.args(["/T", "/F", "/PID", &pid.to_string()])
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = taskkill.status();
windows_tree::wait_all(&started, TREE_EXIT_WAIT);
}
#[cfg(windows)]
mod windows_tree {
use std::collections::HashSet;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::time::{Duration, Instant};
use windows_sys::Win32::Foundation::{FILETIME, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
};
/// Every process `root` started, directly or not, opened so it can be waited for. A
/// parent's id is reused once it is gone, so a process counts only if it started after
/// its parent.
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
let processes = processes();
let mut seen = HashSet::from([root]);
let mut parents = vec![(root, root_started)];
let mut found = Vec::new();
while let Some((parent, parent_started)) = parents.pop() {
for &(pid, ppid) in &processes {
if ppid != parent || !seen.insert(pid) {
continue;
}
let Some(handle) = open(pid) else { continue };
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
parents.push((pid, at));
found.push(handle);
}
}
}
found
}
pub(super) fn wait_all(processes: &[OwnedHandle], within: Duration) {
let until = Instant::now() + within;
for process in processes {
let left = until.saturating_duration_since(Instant::now());
// SAFETY: a live process handle opened with SYNCHRONIZE access.
unsafe { WaitForSingleObject(process.as_raw_handle(), left.as_millis().min(u32::MAX as u128 - 1) as u32) };
}
}
/// (process id, parent process id) of every running process.
fn processes() -> Vec<(u32, u32)> {
let mut out = Vec::new();
// SAFETY: a snapshot handle owned here and an entry sized as the API requires.
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot.is_null() || snapshot == INVALID_HANDLE_VALUE {
return out;
}
let snapshot = OwnedHandle::from_raw_handle(snapshot);
let mut entry = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
let mut more = Process32FirstW(snapshot.as_raw_handle(), &mut entry);
while more != 0 {
out.push((entry.th32ProcessID, entry.th32ParentProcessID));
more = Process32NextW(snapshot.as_raw_handle(), &mut entry);
}
}
out
}
fn open(pid: u32) -> Option<OwnedHandle> {
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
// SAFETY: as above.
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
}
/// When the process started, in 100 ns ticks since 1601.
fn started(process: &OwnedHandle) -> Option<u64> {
let (mut created, mut exited, mut kernel, mut user) =
(FILETIME::default(), FILETIME::default(), FILETIME::default(), FILETIME::default());
// SAFETY: a live process handle opened with query access; four out-parameters.
let ok = unsafe { GetProcessTimes(process.as_raw_handle(), &mut created, &mut exited, &mut kernel, &mut user) };
(ok != 0).then(|| (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
}
}
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
-45
View File
@@ -713,19 +713,6 @@ impl Default for PythonSettings {
pub struct PhpSettings {
/// `php` CLI executable; empty = the one on `PATH`.
pub interpreter: String,
/// PHP version the language server checks the code against (`7.4`); empty = from
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
pub language_level: String,
/// Folders outside the project whose code the language server indexes too (a platform a
/// module extends); absolute, `~/`, or relative to the project.
pub include_paths: Vec<String>,
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
/// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
/// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
/// `~/`, or relative to the project.
pub phpstan_config: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
@@ -832,8 +819,6 @@ 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,
@@ -870,20 +855,12 @@ pub enum GradleDistribution {
pub struct MavenSettings {
/// Project-relative root POMs removed from the Maven project model.
pub unlinked_projects: Vec<String>,
/// Project-relative POMs outside the root reactor added as independent top-level projects.
pub linked_projects: Vec<String>,
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
pub home: String,
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
pub user_settings: String,
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
pub local_repository: String,
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
/// its own Maven, so `home` and the project's `mvnw` are bypassed when it is on.
pub daemon: bool,
/// Maven Daemon home (or its `mvnd` executable); empty = `$MVND_HOME`, else
/// `mvnd` on `PATH` / common install dirs. Only used when `daemon` is on.
pub mvnd_home: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -1115,33 +1092,12 @@ mod maven_linkage_tests {
assert!(encoded.contains("unlinkedProjects"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
#[test]
fn daemon_settings_round_trip() {
// Old settings files predate the daemon fields; `serde(default)` keeps them loadable.
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(!old.daemon && old.mvnd_home.is_empty());
let settings = MavenSettings { daemon: true, mvnd_home: "/opt/mvnd".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains("\"daemon\":true") && encoded.contains("mvndHome"));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
}
#[cfg(test)]
mod gradle_settings_tests {
use super::*;
#[test]
fn absent_and_explicitly_empty_gradle_links_remain_distinct() {
let automatic: GradleSettings = serde_json::from_str("{}").unwrap();
let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap();
assert_eq!(automatic.linked_projects, None);
assert_eq!(unlinked.linked_projects, Some(vec![]));
let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap();
assert_eq!(restored.linked_projects, Some(vec![]));
}
#[test]
fn defaults_and_round_trip() {
let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap();
@@ -1154,7 +1110,6 @@ 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);
-15
View File
@@ -30,21 +30,6 @@ 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);
-137
View File
@@ -1,137 +0,0 @@
//! 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"));
}
}
+3 -28
View File
@@ -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, SimpleCov resultset) and paths recorded on another
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
//! machine are mapped into the project by their longest existing suffix.
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
use ide_core::{IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
@@ -11,7 +11,6 @@ pub(crate) enum Format {
Lcov,
GoProfile,
Jacoco,
SimpleCov,
}
pub(crate) fn detect(text: &str) -> Option<Format> {
@@ -19,9 +18,6 @@ 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);
@@ -34,7 +30,6 @@ 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")
@@ -55,7 +50,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
}
None => {
return Err(IdeError::invalid(format!(
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
path.display()
)));
}
@@ -130,29 +125,9 @@ 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();
-8
View File
@@ -10,7 +10,6 @@
//! | 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 |
//!
@@ -21,7 +20,6 @@ mod import;
mod jacoco;
mod lcov;
mod plan;
mod simplecov;
pub use import::import;
pub use plan::prepare;
@@ -85,8 +83,6 @@ 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>`).
@@ -189,10 +185,6 @@ 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"))
-128
View File
@@ -67,7 +67,6 @@ 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.",
@@ -235,65 +234,6 @@ 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.");
@@ -458,74 +398,6 @@ 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();
-74
View File
@@ -1,74 +0,0 @@
//! 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());
}
}
+1 -7
View File
@@ -72,13 +72,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install the Kotlin Debug Adapter from Settings > 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.
+6 -61
View File
@@ -302,23 +302,6 @@ 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))
}
@@ -587,10 +570,6 @@ 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()
@@ -610,27 +589,17 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
if config.adapter == "debugpy" {
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
spec.command = python;
}
}
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
// is Flutter-only). Plain Dart projects keep the configured `dart`.
if config.adapter == "dart" && spec.command == "dart" {
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
if force_flutter || is_flutter_project(&cwd, program) {
spec.command = "flutter".into();
}
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
@@ -682,19 +651,8 @@ impl DebugManager {
{
arguments["project"] = json!(real);
}
if config.adapter == "codelldb" {
if arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
// The Debug Console and Evaluate Expression take expressions, as with every other
// debugger; LLDB commands still run with a leading backtick.
match arguments.get_mut("_adapterSettings") {
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
Some(Value::Object(settings)) => {
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
}
Some(_) => {}
}
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
arguments["sourceLanguages"] = json!(["rust"]);
}
let port = debug_server.map(|s| s.port);
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
@@ -1266,19 +1224,6 @@ impl DebugManager {
Ok(())
}
/// 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",
+6 -10
View File
@@ -111,8 +111,8 @@ impl Session {
if let Some(path) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.adapter_path(path)) {
return path;
}
if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
let native = windows_source_path(&path.to_string_lossy());
if self.adapter == "java-debug" {
let native = java_source_path(&path.to_string_lossy());
self.resolved_paths.lock().insert(native.clone(), path.to_path_buf());
return native;
}
@@ -133,11 +133,7 @@ impl Session {
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
let key = if self.adapter == "java-debug" || (cfg!(windows) && self.adapter == "netcoredbg") {
windows_source_path(path)
} else {
PathBuf::from(path)
};
let key = if self.adapter == "java-debug" { java_source_path(path) } else { PathBuf::from(path) };
self.resolved_paths.lock().get(&key).cloned().or_else(|| {
(self.adapter == "java-debug" && is_windows_source_path(path))
.then(|| PathBuf::from(path.replace('\\', "/")))
@@ -269,8 +265,8 @@ impl Session {
}
}
// java-debug and Windows netcoredbg need native separators for source lookup.
// Keep DAP path mode, but normalize Windows drive and UNC paths.
// java-debug treats a forward-slash drive path as a URI (scheme "C").
// Keep DAP path mode, but use native Windows separators for Java source lookup.
fn is_windows_source_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\'))
@@ -278,7 +274,7 @@ fn is_windows_source_path(path: &str) -> bool {
|| path.starts_with(r"\\")
}
fn windows_source_path(path: &str) -> PathBuf {
fn java_source_path(path: &str) -> PathBuf {
if is_windows_source_path(path) { PathBuf::from(path.replace('/', r"\")) } else { PathBuf::from(path) }
}
-65
View File
@@ -698,37 +698,6 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
assert_eq!(launch["type"], "pwa-node");
}
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
/// and the Debug Console send expressions, so its console evaluates them. A launch
/// configuration that picks a console mode keeps it.
#[tokio::test]
async fn codelldb_console_evaluates_expressions() {
let launch_with = |extra: Value| async move {
let h = harness(Script::default());
let config = LaunchConfig {
name: "demo".into(),
adapter: "codelldb".into(),
request: "launch".into(),
program: Some("/p/target/debug/demo".into()),
args: vec![],
cwd: Some("/p".into()),
env: BTreeMap::new(),
extra_json: Some(extra.to_string()),
};
h.start(config).await.unwrap();
h.request("launch")["arguments"].clone()
};
let launch = launch_with(json!({})).await;
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
let launch =
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
}
/// An adapter that is already listening (java-debug inside jdtls): the client
/// connects over TCP to the `debugServer` port instead of spawning anything.
#[tokio::test]
@@ -954,7 +923,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
ui_mode: None,
framework_ui_modes: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
language_servers: vec![],
debugger: Some(ide_language::DebuggerConfig {
@@ -1141,23 +1109,6 @@ async fn kotlin_official_release_ten_no_delay_runs() {
}
}
#[cfg(windows)]
#[tokio::test]
async fn netcoredbg_windows_source_paths_round_trip() {
let client = "D:/work/LoginController.cs";
let native = r"D:\work\LoginController.cs";
let h = harness(Script { source_path: Some(native.into()), ..Script::default() });
let path = PathBuf::from(client);
h.manager.set_breakpoints(path.clone(), vec![bp(25)]).await.unwrap();
let mut cfg = config();
cfg.adapter = "netcoredbg".into();
let id = h.start(cfg).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], native);
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_deref(), Some(path.as_path()));
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn java_windows_source_paths_for_launch_and_attach() {
for request in ["launch", "attach"] {
@@ -1269,19 +1220,3 @@ 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();
}
-1
View File
@@ -29,4 +29,3 @@ uuid.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
+11 -56
View File
@@ -150,7 +150,6 @@ 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())
@@ -178,17 +177,6 @@ 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());
}
@@ -200,7 +188,7 @@ impl ClickhousePool {
};
let client = self.client()?;
let query_id = uuid::Uuid::new_v4().to_string();
let request = self.request(&client, &query_id, skip.saturating_add(max_rows), *session).body(sql.to_owned());
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
let started = Instant::now();
let fetch = async {
let mut response = request.send().await.map_err(http_error)?;
@@ -228,7 +216,7 @@ impl ClickhousePool {
}
(Vec::new(), Vec::new(), false)
} else {
let mut reader = RowReader::new(skip, max_rows);
let mut reader = RowReader::new(max_rows);
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
reader.push(&chunk)?;
}
@@ -321,13 +309,11 @@ impl ClickhousePool {
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
/// Decodes a streamed [`FORMAT`] body. Reads past the first `skip` rows unparsed (a later
/// page), then keeps `max_rows` rows; later rows only set `has_more` and are read past
/// unparsed, so a grid limit bounds memory however large the block
/// `result_overflow_mode=break` lets the server send. A line that is not a JSON array
/// 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
/// 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>>,
@@ -338,17 +324,8 @@ struct RowReader {
}
impl RowReader {
fn new(skip: usize, max_rows: usize) -> Self {
Self {
skip,
max_rows,
pending: Vec::new(),
names: None,
types: None,
rows: Vec::new(),
more: false,
exception: None,
}
fn new(max_rows: usize) -> Self {
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
}
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
@@ -379,15 +356,9 @@ impl RowReader {
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
return Ok(());
}
if self.names.is_some() && self.types.is_some() {
if self.skip > 0 {
self.skip -= 1;
return Ok(());
}
if self.rows.len() >= self.max_rows {
self.more = true;
return Ok(());
}
if self.names.is_some() && self.types.is_some() && 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)));
@@ -576,11 +547,7 @@ mod tests {
}
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
decode_from(body, 0, max_rows, chunk)
}
fn decode_from(body: &[u8], skip: usize, max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
let mut reader = RowReader::new(skip, max_rows);
let mut reader = RowReader::new(max_rows);
for part in body.chunks(chunk) {
reader.push(part)?;
}
@@ -619,18 +586,6 @@ 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");
+10 -49
View File
@@ -337,13 +337,10 @@ fn result_columns<R: Row>(row: &R) -> Vec<ResultColumn> {
.collect()
}
/// 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`.
/// Streams the results of `$sql` on executor `$exec`, keeping at most `$max` rows
/// with their cells read as `$mode`.
macro_rules! fetch {
($exec:expr, $sql:expr, $max:expr, $mode:expr) => {
fetch!($exec, $sql, 0usize, $max, $mode)
};
($exec:expr, $sql:expr, $skip:expr, $max:expr, $mode:expr) => {{
($exec:expr, $sql: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();
@@ -352,7 +349,6 @@ 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 {
@@ -361,10 +357,6 @@ 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;
@@ -394,8 +386,7 @@ async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
Ok(())
}
/// 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> {
async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, max: usize) -> IdeResult<Fetched> {
let mut stream = conn.simple_query(sql).await.map_err(mssql_err)?;
let first = Some(Instant::now());
let columns = stream
@@ -408,13 +399,8 @@ async fn mssql_fetch(conn: &mut MssqlClient, sql: &str, skip: usize, max: usize)
.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;
@@ -748,7 +734,7 @@ impl Pool {
let fetched: IdeResult<Fetched> = match self {
Pool::SqlServer(p) => {
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
mssql_fetch(&mut conn, sql, 0, max).await
mssql_fetch(&mut conn, sql, max).await
}
_ => with_pool!(self, p => fetch!(p, sql, max, Cells::Text)),
};
@@ -758,23 +744,11 @@ impl Pool {
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
self.execute_from(sql, 0, max_rows, cancel).await
}
/// [`execute`](Self::execute), reading past the first `skip` rows without keeping them
/// (a later page of a result: only that page is decoded and returned).
pub async fn execute_from(
&self,
sql: &str,
skip: usize,
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
if let Pool::Clickhouse(p) = self {
return p.execute_from(sql, skip, max_rows, cancel).await;
return p.execute(sql, max_rows, cancel).await;
}
let mut conn = Held::acquire(self).await?;
let r = conn.execute_from(self, sql, skip, max_rows, cancel).await;
let r = conn.execute(self, sql, max_rows, cancel).await;
if cancel.is_cancelled() {
conn.discard().await;
return Err(IdeError::cancelled());
@@ -900,22 +874,9 @@ 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_from(sql, skip, max_rows, cancel).await;
return c.execute(sql, max_rows, cancel).await;
}
let started = Instant::now();
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
@@ -926,7 +887,7 @@ impl Held {
None => None,
};
let r = tokio::select! {
r = async { fetch!(&mut **$conn, sql, skip, max_rows, Cells::Display) } => r,
r = async { fetch!(&mut **$conn, sql, max_rows, Cells::Display) } => r,
() = cancel.cancelled() => Err(IdeError::cancelled()),
};
if cancel.is_cancelled() {
@@ -955,7 +916,7 @@ impl Held {
Held::SqlServer(c) => {
// Tiberius has no attention/cancel API; stop reading and discard the connection.
tokio::select! {
r = mssql_fetch(c, sql, skip, max_rows) => r?,
r = mssql_fetch(c, sql, max_rows) => r?,
() = cancel.cancelled() => return Err(IdeError::cancelled()),
}
}
+19 -171
View File
@@ -213,8 +213,6 @@ pub struct DbManager {
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
/// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]).
global: Option<(PathBuf, SecretStore)>,
history: Arc<HistoryStore>,
/// Records executed statements in the history off the async threads, in order.
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
@@ -341,7 +339,6 @@ impl DbManager {
}
Self {
saved_passwords: SecretStore::new(passwords, &root),
global: None,
history,
recorder,
root,
@@ -354,42 +351,8 @@ impl DbManager {
}
}
/// Also lists the global data sources stored in `dir` (the app data directory).
pub fn with_global_sources(mut self, dir: PathBuf) -> Self {
let passwords = self.saved_passwords.scoped(&dir);
self.global = Some((dir, passwords));
self
}
/// The project's data sources, then the global ones (a project one wins on the same id).
pub fn sources(&self) -> IdeResult<Vec<DataSource>> {
let mut all = source::load(&self.root)?;
let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect();
all.extend(global);
Ok(all)
}
fn global_sources(&self) -> Vec<DataSource> {
let Some((dir, _)) = &self.global else { return Vec::new() };
source::load_global(dir).unwrap_or_else(|e| {
tracing::warn!("global data sources: {}", e.message);
Vec::new()
})
}
/// Where the saved passwords of data source `id` live: app-wide for a global one.
fn saved_for(&self, id: &str) -> &SecretStore {
match &self.global {
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
_ => &self.saved_passwords,
}
}
fn store_of(&self, source: &DataSource) -> &SecretStore {
match &self.global {
Some((_, store)) if source.global => store,
_ => &self.saved_passwords,
}
source::load(&self.root)
}
/// The connection settings of target `target` ([`DataSource::resolved`]), with
@@ -409,43 +372,18 @@ impl DbManager {
}
/// Saves the data source list; connections of removed or changed sources are closed.
/// Global ones (`global`) go to the app-wide list, the others to the project's.
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
let old = self.sources().unwrap_or_default();
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
match &self.global {
// Rewritten only when it changes: other projects' windows edit the same file.
Some((dir, _)) => {
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
source::save_global(dir, &global)?;
}
}
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
None => {}
}
source::save(&self.root, &project)?;
source::save(&self.root, &sources)?;
for o in old {
match sources.iter().find(|s| s.id == o.id) {
Some(n) if *n == o => {}
gone_or_changed => {
let keys = [o.id.clone(), secrets::ssh_key(&o.id)];
match gone_or_changed {
None => {
for k in &keys {
self.session_passwords.lock().remove(k);
self.store_of(&o).delete(k);
}
if gone_or_changed.is_none() {
for k in [o.id.clone(), secrets::ssh_key(&o.id)] {
self.session_passwords.lock().remove(&k);
self.saved_passwords.delete(&k);
}
// Made global or project-level: its saved passwords move along.
Some(n) if n.global != o.global => {
for k in &keys {
if let Some(p) = self.store_of(&o).get(k) {
self.store_of(n).set(k, &p)?;
self.store_of(&o).delete(k);
}
}
}
Some(_) => {}
}
self.disconnect_source(&o.id).await;
}
@@ -457,7 +395,7 @@ impl DbManager {
/// Remembers `password` for the session, and in the saved passwords when
/// the data source saves passwords (`None` forgets it).
pub async fn set_password(&self, id: &str, password: Option<String>, save: bool) -> IdeResult<()> {
self.remember(id, id.to_owned(), password, save).await
self.remember(id.to_owned(), password, save).await
}
/// [`DbManager::set_password`] for the SSH password / key passphrase.
@@ -469,10 +407,10 @@ impl DbManager {
self.ssh.set_secret(&c, secret, save);
return Ok(());
}
self.remember(id, secrets::ssh_key(id), secret, save).await
self.remember(secrets::ssh_key(id), secret, save).await
}
async fn remember(&self, id: &str, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
async fn remember(&self, key: String, secret: Option<String>, save: bool) -> IdeResult<()> {
match &secret {
Some(p) => {
self.session_passwords.lock().insert(key.clone(), p.clone());
@@ -481,11 +419,10 @@ impl DbManager {
self.session_passwords.lock().remove(&key);
}
}
let store = self.saved_for(id);
let result = match (secret, save) {
(Some(p), true) => store.set(&key, &p),
(Some(p), true) => self.saved_passwords.set(&key, &p),
_ => {
store.delete(&key);
self.saved_passwords.delete(&key);
Ok(())
}
};
@@ -496,18 +433,18 @@ impl DbManager {
Ok(())
}
async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option<String> {
async fn secret(&self, key: String, saved: bool) -> Option<String> {
if let Some(p) = self.session_passwords.lock().get(&key) {
return Some(p.clone());
}
if !saved {
return None;
}
self.store_of(source).get(&key)
self.saved_passwords.get(&key)
}
async fn password(&self, source: &DataSource) -> Option<String> {
match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await {
Some(p) => Some(p),
None => source.url_password(),
}
@@ -563,7 +500,7 @@ impl DbManager {
let session = if config.is_empty() {
let secret = match ssh_secret {
Some(s) => Some(s),
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
};
let target = SshTarget {
host: ssh.host.clone(),
@@ -1009,20 +946,7 @@ impl DbManager {
max_rows: usize,
cancel: &CancellationToken,
) -> IdeResult<QueryResult> {
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 r = self.run(id, statement, max_rows, cancel).await;
let ran = match &r {
Ok(_) => true,
Err(e) => !matches!(e.kind, ErrorKind::Cancelled | ErrorKind::PermissionDenied),
@@ -1057,7 +981,7 @@ 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), 0, MAX_META_ROWS, cancel).await?;
let result = self.run(id, &plan::explain_statement(driver, statement), MAX_META_ROWS, cancel).await?;
plan::parse(driver, &result)
}
@@ -1065,14 +989,13 @@ 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_from(statement, skip, max_rows, cancel).await;
return pool.execute(statement, max_rows, cancel).await;
};
if pool.driver() == DbDriver::Clickhouse {
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
@@ -1084,7 +1007,7 @@ impl DbManager {
*held = Some(conn);
}
let conn = held.as_mut().expect("opened above");
let r = conn.execute_from(&pool, statement, skip, max_rows, cancel).await;
let r = conn.execute(&pool, statement, max_rows, cancel).await;
if cancel.is_cancelled() {
// The server rolls the transaction back with the connection.
held.take().expect("opened above").discard().await;
@@ -1175,81 +1098,6 @@ impl DbManager {
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::testing::files_containing;
use std::path::Path;
fn manager(dir: &Path) -> DbManager {
ide_ssh::secrets::testing::use_memory_store();
let data = dir.join("data");
let ssh = Arc::new(SshManager::new(data.join("ssh-passwords.json")));
DbManager::new(dir.join("project"), data.join("db-passwords.json"), data.join("db-history.json"), ssh)
}
#[tokio::test]
async fn saved_passwords_stay_off_disk() {
let tmp = tempfile::tempdir().unwrap();
manager(tmp.path()).set_password("s1", Some("hunter2-db".into()), true).await.unwrap();
manager(tmp.path()).set_ssh_secret("s1", Some("hunter2-tunnel".into()), true).await.unwrap();
assert_eq!(files_containing(tmp.path(), "hunter2-db"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "hunter2-tunnel"), Vec::<PathBuf>::new());
let reopened = manager(tmp.path());
assert_eq!(reopened.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("hunter2-db"));
assert_eq!(
reopened.secret(&DataSource::default(), secrets::ssh_key("s1"), true).await.as_deref(),
Some("hunter2-tunnel")
);
}
#[tokio::test]
async fn moves_plaintext_passwords_of_older_versions_off_disk() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("project").to_string_lossy().into_owned();
let legacy = serde_json::json!({ project: { "s1": "old-db-pw" }, "/other": { "s2": "other-pw" } });
std::fs::create_dir_all(tmp.path().join("data")).unwrap();
std::fs::write(tmp.path().join("data/db-passwords.json"), legacy.to_string()).unwrap();
let m = manager(tmp.path());
assert_eq!(m.secret(&DataSource::default(), "s1".into(), true).await.as_deref(), Some("old-db-pw"));
assert_eq!(files_containing(tmp.path(), "old-db-pw"), Vec::<PathBuf>::new());
assert_eq!(files_containing(tmp.path(), "other-pw"), Vec::<PathBuf>::new());
let other = SecretStore::new(tmp.path().join("data/db-passwords.json"), Path::new("/other"));
assert_eq!(other.get("s2").as_deref(), Some("other-pw"));
}
#[tokio::test]
async fn global_data_sources_are_shared_by_projects_with_their_passwords() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two"));
let manager = |root: &std::path::Path| {
let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json")));
DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh)
.with_global_sources(app.clone())
};
let (a, b) = (manager(&one), manager(&two));
let source = |id: &str, global: bool| DataSource {
id: id.into(),
name: id.into(),
driver: DbDriver::Postgres,
save_password: true,
global,
..Default::default()
};
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
a.set_password("shared", Some("pw".into()), true).await.unwrap();
// Made global: listed in the other project too, with its saved password.
a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap();
let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::<Vec<_>>();
assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]);
assert_eq!(ids(&b), [("shared".into(), true)]);
assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw"));
let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap();
assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}");
assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global"));
// Made project-level again: gone from the other project, the password stays with it.
a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap();
assert!(b.sources().unwrap().is_empty());
assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw"));
}
#[tokio::test]
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
+55 -31
View File
@@ -1,15 +1,20 @@
//! Saved data source passwords, in the OS credential store like the other saved
//! passwords ([`SecretFile`]). One index file for all projects; each credential is
//! keyed by project root, then credential key.
//! Saved data source passwords, in the app's data directory rather than the OS
//! credential store (no system prompts). One JSON file for all projects, keyed
//! by project root, then credential key; owner-only on Unix.
use ide_core::IdeResult;
use ide_ssh::secrets::SecretFile;
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Project root → credential key → secret.
type Contents = BTreeMap<String, BTreeMap<String, String>>;
/// Serialises read-modify-write across the projects sharing the file.
static LOCK: Mutex<()> = Mutex::new(());
pub struct SecretStore {
path: PathBuf,
file: SecretFile,
project: String,
}
@@ -18,41 +23,61 @@ pub fn ssh_key(id: &str) -> String {
format!("{id}#ssh")
}
fn file_key(project: &str, key: &str) -> String {
format!("{project}|{key}")
}
/// Older versions' file: project root → credential key → secret.
fn legacy(value: serde_json::Value) -> Option<BTreeMap<String, String>> {
let all: BTreeMap<String, BTreeMap<String, String>> = serde_json::from_value(value).ok()?;
Some(
all.into_iter()
.flat_map(|(project, keys)| keys.into_iter().map(move |(k, s)| (file_key(&project, &k), s)))
.collect(),
)
}
impl SecretStore {
pub fn new(path: PathBuf, root: &Path) -> Self {
let file = SecretFile::with_legacy(path.clone(), legacy);
Self { path, file, project: root.to_string_lossy().into_owned() }
}
/// The same file, keyed by `scope` instead (global data sources: the app data directory).
pub fn scoped(&self, scope: &Path) -> Self {
Self::new(self.path.clone(), scope)
Self { path, project: root.to_string_lossy().into_owned() }
}
pub fn get(&self, key: &str) -> Option<String> {
self.file.get(&file_key(&self.project, key))
let _g = LOCK.lock();
self.read().remove(&self.project)?.remove(key)
}
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
self.file.set(&file_key(&self.project, key), secret)
let _g = LOCK.lock();
let mut all = self.read();
all.entry(self.project.clone()).or_default().insert(key.to_owned(), secret.to_owned());
self.write(&all)
}
pub fn delete(&self, key: &str) {
self.file.delete(&file_key(&self.project, key))
let _g = LOCK.lock();
let mut all = self.read();
let Some(project) = all.get_mut(&self.project) else { return };
if project.remove(key).is_none() {
return;
}
if project.is_empty() {
all.remove(&self.project);
}
if let Err(e) = self.write(&all) {
tracing::warn!("saved passwords: {}", e.message);
}
}
fn read(&self) -> Contents {
match std::fs::read(&self.path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("saved passwords: {}: {e}", self.path.display());
Contents::new()
}),
Err(_) => Contents::new(),
}
}
fn write(&self, all: &Contents) -> IdeResult<()> {
let err = |e: std::io::Error| IdeError::unavailable(format!("saved passwords: {e}"));
if let Some(dir) = self.path.parent() {
std::fs::create_dir_all(dir).map_err(err)?;
}
let tmp = self.path.with_extension("json.tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
std::fs::rename(&tmp, &self.path).map_err(err)
}
}
@@ -62,7 +87,6 @@ mod tests {
#[test]
fn keeps_projects_apart_and_drops_empty_ones() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("db-passwords.json");
let a = SecretStore::new(file.clone(), Path::new("/p/a"));
+10 -42
View File
@@ -1,7 +1,6 @@
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and
//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the
//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`],
//! the others only in memory for the session.
//! Data sources: per-project connection settings in `.openidea/data-sources.toml`.
//! Passwords never go there: saved ones live in [`crate::secrets`], the others
//! only in memory for the session.
use ide_core::{IdeError, IdeResult, IpcPath, project_dir};
use serde::{Deserialize, Serialize};
@@ -182,9 +181,6 @@ pub struct DataSource {
pub options: DbOptions,
pub introspection: DbIntrospection,
pub properties: Vec<DbProperty>,
/// Stored app-wide and listed in every project. Implied by the file it is read from,
/// so not written to it.
pub global: bool,
}
impl Default for DataSource {
@@ -213,7 +209,6 @@ impl Default for DataSource {
options: DbOptions::default(),
introspection: DbIntrospection::default(),
properties: Vec::new(),
global: false,
}
}
}
@@ -402,32 +397,9 @@ struct SourcesFile {
sources: Vec<DataSource>,
}
/// The project's data sources.
pub fn load(root: &Path) -> IdeResult<Vec<DataSource>> {
read(&project_dir::path(root, FILE))
}
/// Saves the project's data sources.
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&project_dir::path(root, FILE), sources)
}
/// The global data sources stored in `dir` (the app data directory).
pub fn load_global(dir: &Path) -> IdeResult<Vec<DataSource>> {
let mut sources = read(&dir.join(FILE))?;
for s in &mut sources {
s.global = true;
}
Ok(sources)
}
/// Saves the global data sources in `dir`.
pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> {
write(&dir.join(FILE), sources)
}
fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
let text = match std::fs::read_to_string(path) {
let path = project_dir::path(root, FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
@@ -436,23 +408,19 @@ fn read(path: &Path) -> IdeResult<Vec<DataSource>> {
Ok(file.sources)
}
fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> {
pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> {
for s in sources {
if s.id.is_empty() || s.name.trim().is_empty() {
return Err(IdeError::invalid("every data source needs an id and a name"));
}
}
let path = project_dir::path(root, FILE);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string());
let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?;
if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) {
for table in list.iter_mut().filter_map(toml::Value::as_table_mut) {
table.remove("global");
}
}
std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?;
let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() })
.map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(path, text)?;
Ok(())
}
+1 -25
View File
@@ -1,9 +1,7 @@
//! 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, QueryResult, SortKey,
};
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbOptions, DbProperty, DbRowEdit, DbTableKind, SortKey};
async fn setup() -> (tempfile::TempDir, DbManager) {
let tmp = tempfile::tempdir().unwrap();
@@ -125,28 +123,6 @@ 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;
+1 -3
View File
@@ -79,9 +79,7 @@ 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 => {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Clipboard => "Read and write the clipboard 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"),
+1 -23
View File
@@ -364,7 +364,7 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
sorted.dedup();
let mut hasher = blake3::Hasher::new();
for rel in sorted {
hasher.update(hashed_path(rel).as_bytes());
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update_reader(std::fs::File::open(dir.join(rel))?)?;
hasher.update(&[0]);
@@ -372,12 +372,6 @@ pub fn package_hash(dir: &Path, files: &[PathBuf]) -> std::io::Result<String> {
Ok(hasher.finalize().to_hex().to_string())
}
/// A package-relative path as hashed: `/`-separated on every OS, so a package has one digest
/// (a walked `web\index.html` on Windows is `web/index.html` everywhere else).
fn hashed_path(rel: &Path) -> String {
rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -486,20 +480,4 @@ mod tests {
let groups = by_risk(&caps(&["storage", "process:git", "net:x.io"]));
assert_eq!(groups.iter().map(|g| g.0).collect::<Vec<_>>(), [Risk::High, Risk::Medium, Risk::Low]);
}
#[test]
fn hashes_paths_with_forward_slashes_on_every_os() {
assert_eq!(hashed_path(Path::new("web/index.html")), "web/index.html");
assert_eq!(hashed_path(Path::new("plugin.wasm")), "plugin.wasm");
#[cfg(windows)]
assert_eq!(hashed_path(Path::new(r"web\index.html")), "web/index.html");
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("web")).unwrap();
std::fs::write(tmp.path().join("web").join("index.html"), "<p>").unwrap();
// A walked path (native separators) hashes like the manifest's `/` spelling.
assert_eq!(
package_hash(tmp.path(), &[Path::new("web").join("index.html")]).unwrap(),
package_hash(tmp.path(), &["web/index.html".into()]).unwrap()
);
}
}
+1 -1
View File
@@ -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.1.0";
pub const ENGINE_VERSION: &str = "1.0.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.
@@ -33,9 +33,7 @@ usage: openidea-plugin <command> [options]
commands:
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
create a plugin; T = command (default), theme, icon-theme, language. A Rust
plugin depends on the SDK of this tool's release (git); --sdk PATH uses a
local sdk/rust/openidea-plugin instead
create a plugin; T = command (default), theme, icon-theme, language
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it
link [dir] load the folder in the IDE without installing it
@@ -179,7 +177,7 @@ fn cmd_new(args: &[String]) -> Result<(), String> {
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let dir = canonical(&dir)?;
let sdk = match template {
templates::Template::Command => Some(sdk_dependency(a.value("sdk"), &dir)?),
templates::Template::Command => Some(sdk_path(a.value("sdk"), &dir)?),
_ => None,
};
let vars = templates::Vars { id: &id, name: &name, sdk: sdk.as_deref() };
@@ -234,16 +232,12 @@ fn title_case(s: &str) -> String {
.join(" ")
}
/// The SDK dependency written into a new plugin's Cargo.toml: by path with `--sdk`, or
/// when the plugin is inside the source tree this tool was built from; otherwise the
/// repository at this tool's release tag.
fn sdk_dependency(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
/// The SDK dependency path written into a new plugin's Cargo.toml: `--sdk`,
/// or the SDK of the source tree this tool was built from.
fn sdk_path(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, String> {
let sdk = match explicit {
Some(p) => PathBuf::from(p),
None => match dunce::canonicalize(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")) {
Ok(tree) if plugin_dir.starts_with(&tree) => tree.join("sdk/rust/openidea-plugin"),
_ => return Ok(format!("{{ git = \"{REPOSITORY}\", tag = \"v{}\" }}", env!("CARGO_PKG_VERSION"))),
},
None => PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin")),
};
let sdk = dunce::canonicalize(&sdk).map_err(|_| {
format!("the plugin SDK was not found at {}; pass --sdk <path to sdk/rust/openidea-plugin>", sdk.display())
@@ -252,11 +246,9 @@ fn sdk_dependency(explicit: Option<&str>, plugin_dir: &Path) -> Result<String, S
return Err(format!("{} is not the openidea-plugin SDK (no Cargo.toml)", sdk.display()));
}
let path = relative(plugin_dir, &sdk).unwrap_or(sdk);
Ok(format!("{{ path = \"{}\" }}", path.to_string_lossy().replace('\\', "/")))
Ok(path.to_string_lossy().replace('\\', "/"))
}
const REPOSITORY: &str = "https://github.com/l0ng-ai/openidea";
/// `to` relative to `from` when they share a root (keeps packages in a repo movable).
fn relative(from: &Path, to: &Path) -> Option<PathBuf> {
let (f, t): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
@@ -789,27 +781,6 @@ mod tests {
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
}
#[test]
fn new_plugins_depend_on_the_sdk_by_path_only_in_its_source_tree() {
let tree = dunce::canonicalize(concat!(env!("CARGO_MANIFEST_DIR"), "/../..")).unwrap();
assert_eq!(
sdk_dependency(None, &tree.join("plugins/official/new")).unwrap(),
"{ path = \"../../../sdk/rust/openidea-plugin\" }"
);
let outside = dunce::canonicalize(std::env::temp_dir()).unwrap().join("my-plugin");
assert_eq!(
sdk_dependency(None, &outside).unwrap(),
format!("{{ git = \"https://github.com/l0ng-ai/openidea\", tag = \"v{}\" }}", env!("CARGO_PKG_VERSION"))
);
let sdk = tree.join("sdk/rust/openidea-plugin");
assert!(sdk_dependency(Some(sdk.to_str().unwrap()), &outside).unwrap().starts_with("{ path = "));
assert!(
sdk_dependency(Some(tree.join("wit").to_str().unwrap()), &outside)
.unwrap_err()
.contains("not the openidea-plugin SDK")
);
}
/// A theme package `id` signed by `publisher` with `key`, packed into `out`.
fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) {
signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out);
@@ -930,48 +901,13 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
/// `cargo test` of a new Rust plugin (its own tests against the mock host) with this SDK.
#[test]
fn the_command_template_builds_against_the_sdk() {
let dir = std::env::temp_dir().join(format!("openidea-plugin-template-build-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dunce::canonicalize(&dir).unwrap();
let plugin = dir.join("plugin");
let sdk = concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin");
let sdk = sdk_dependency(Some(sdk), &plugin).unwrap();
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some(&sdk) };
for (rel, content) in templates::render(templates::Template::Command, &vars) {
let path = plugin.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
// The SDK's lockfile keeps the versions its own CI builds with (the resolver also
// looks up the wasm-only dependencies, which may not be downloaded yet).
let sdk_lock = concat!(env!("CARGO_MANIFEST_DIR"), "/../../sdk/rust/openidea-plugin/Cargo.lock");
std::fs::copy(sdk_lock, plugin.join("Cargo.lock")).unwrap();
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["test", "--manifest-path"])
.arg(plugin.join("Cargo.toml"))
.env("CARGO_TARGET_DIR", dir.join("target"))
.output()
.unwrap();
assert!(
out.status.success(),
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn every_template_is_a_valid_package() {
for t in templates::Template::ALL {
let dir =
std::env::temp_dir().join(format!("openidea-plugin-template-{}-{}", t.label(), std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("{ path = \"../sdk\" }") };
let vars = templates::Vars { id: "acme.sample", name: "Sample", sdk: Some("../sdk") };
for (rel, content) in templates::render(t, &vars) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
@@ -32,7 +32,6 @@ impl Template {
pub struct Vars<'a> {
pub id: &'a str,
pub name: &'a str,
/// The SDK dependency, as a TOML value (`{ path = "…" }`).
pub sdk: Option<&'a str>,
}
@@ -119,7 +118,7 @@ publish = false
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = __SDK__
openidea-plugin = { path = "__SDK__" }
[profile.release]
opt-level = "s"
@@ -131,7 +130,7 @@ panic = "abort"
const COMMAND_LIB: &str = r#"//! __NAME__: an OpenIDEA plugin.
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
use openidea_plugin::{Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
pub struct Main {
@@ -139,7 +138,7 @@ pub struct Main {
}
impl Plugin for Main {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, String> {
match id {
"say-hello" => {
// Instance state lasts until the plugin is idle for a while;
@@ -154,7 +153,7 @@ impl Plugin for Main {
let text = editor.document()?.text;
Ok(Some(format!("{} lines in {}", text.lines().count(), editor.path())))
}
_ => Err(format!("unknown command {id}").into()),
_ => Err(format!("unknown command {id}")),
}
}
}
+54 -141
View File
@@ -10,7 +10,6 @@ mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod phpstan;
pub mod plugins;
mod project;
mod proxy;
@@ -26,7 +25,7 @@ use ide_language::{LanguageRegistry, Syntax};
use ide_run::ProcessManager;
use ide_terminal::TerminalManager;
use ide_workspace::SettingsStore;
use parking_lot::{Mutex, RwLock};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
@@ -75,25 +74,15 @@ pub struct Host {
pub environment: environment::Environment,
/// The HTTP proxy's saved password (see `proxy.rs`).
proxy_password: ide_ssh::secrets::SecretFile,
/// The proxy password when it could not be saved (no credential store).
proxy_session_password: Mutex<Option<String>>,
pub(crate) sink_for: SinkFactory,
owner: fn(&str) -> &str,
pub runtime: tokio::runtime::Handle,
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
/// Shutdowns of projects closed with [`Host::close_project_detached`].
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
/// One open at a time per window (see [`Host::opening`]).
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
}
/// How long exit waits for project shutdown before giving up.
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
@@ -113,7 +102,6 @@ impl Host {
let host = Self {
trust: trust::TrustStore::load(&config_dir, &data_dir),
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
proxy_session_password: Mutex::default(),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
@@ -134,11 +122,8 @@ impl Host {
owner,
runtime,
projects: RwLock::default(),
external_logs: RwLock::default(),
requests: Default::default(),
project_requests: RwLock::default(),
closing: Default::default(),
open_locks: Default::default(),
};
host.apply_proxy();
host
@@ -192,72 +177,45 @@ impl Host {
}
}
/// Closes `window`'s project and waits until its language servers, kernels and
/// debug sessions have shut down. Never call this on the UI event-loop thread:
/// the shutdown runs on the async runtime, whose workers may themselves be
/// waiting for the event loop (IPC responses, window queries). Use
/// [`Self::close_project_detached`] there.
pub fn close_project(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
self.wait_blocking(shutdown);
let project = self.projects.write().remove(window);
if let Some(project) = project {
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
}
}
/// Like [`Self::close_project`], but returns once the project is detached from
/// the window; its workers finish shutting down in the background, and
/// [`Self::shutdown_notebooks_on_exit`] waits for them before the process exits.
pub fn close_project_detached(&self, window: &str) {
if let Some(shutdown) = self.begin_close(window) {
let mut closing = self.closing.lock();
closing.retain(|handle| !handle.is_finished());
closing.push(shutdown);
}
}
/// Detaches the project from `window` and stops what it started, synchronously,
/// so the window can open another project at once. Returns the thread that
/// finishes shutting down its workers.
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
self.external_logs.write().remove(self.owner_window(window));
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
let project = self.projects.write().remove(window)?;
if !project.light {
self.environment.project_closed();
}
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and its language
// servers shut down.
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
Some(self.spawn_project_shutdown(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
))
}
/// Project/window destruction must finish owned kernel cleanup, not enqueue
/// work which can be lost as the desktop runtime exits: the returned thread is
/// joined by the closer or, for a detached close, before exit.
fn spawn_project_shutdown(
/// Synchronous project/window destruction must finish owned kernel cleanup,
/// not enqueue work which can be lost as the desktop runtime exits.
fn shutdown_project_workers_blocking(
&self,
notebooks: Vec<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
) -> std::thread::JoinHandle<()> {
) {
for bridge in &bridges {
bridge.stop();
}
@@ -265,37 +223,25 @@ impl Host {
service.stop_admission();
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
})
}
/// Waits for detached closes, so a project opened next never races the
/// language servers of one still shutting down (e.g. over a shared workspace).
pub(crate) fn wait_detached_closes(&self) {
let pending: Vec<_> = self.closing.lock().drain(..).collect();
for shutdown in pending {
self.wait_blocking(shutdown);
}
}
fn wait_blocking(&self, shutdown: std::thread::JoinHandle<()>) {
let wait = move || {
if let Err(error) = shutdown.join() {
tracing::error!("project shutdown thread failed: {error:?}");
let worker = std::thread::spawn(move || {
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
}
for service in notebooks {
service.shutdown().await;
}
if let Some(lsp) = lsp {
lsp.shutdown_all().await;
}
if let Some((debug, key)) = debug {
debug.stop_key(&key).await;
}
});
});
if let Err(error) = worker.join() {
tracing::error!("notebook shutdown thread failed: {error:?}");
}
};
if tokio::runtime::Handle::try_current()
@@ -307,32 +253,15 @@ impl Host {
}
}
/// Called before the desktop runtime exits, including update/restart exits, on
/// the event-loop thread: waits for detached closes and the open projects'
/// kernels, but no longer than [`EXIT_SHUTDOWN_WAIT`], so a runtime worker
/// stuck on the event loop cannot keep the process from exiting.
/// Called before the desktop runtime exits, including update/restart exits.
pub fn shutdown_notebooks_on_exit(&self) {
let projects = self.projects();
let mut pending: Vec<_> = self.closing.lock().drain(..).collect();
pending.push(self.spawn_project_shutdown(
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
if std::time::Instant::now() >= deadline {
tracing::warn!("project shutdown did not finish within {EXIT_SHUTDOWN_WAIT:?}; exiting anyway");
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
for handle in pending {
if let Err(error) = handle.join() {
tracing::error!("project shutdown thread failed: {error:?}");
}
}
);
}
/// Called before desktop exit, which may bypass document destructors.
@@ -353,9 +282,6 @@ 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();
@@ -385,20 +311,14 @@ impl Host {
}
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_project_read_request(window, id);
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
token.cancel();
}
token
}
/// Read-only work remains cancellable/disposed with its window in restricted projects.
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
let token = self.begin_request(id);
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
token
}
pub fn end_request(&self, id: RequestId) {
for ids in self.project_requests.write().values_mut() {
ids.retain(|entry| *entry != id);
@@ -411,13 +331,6 @@ 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);
}
+3 -13
View File
@@ -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, IpcPath, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, 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: IpcPath,
pub path: String,
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.clone().into(),
path: document.path.to_string_lossy().into_owned(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
@@ -592,16 +592,6 @@ 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();
+4 -85
View File
@@ -8,11 +8,8 @@ use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_core::{IdeError, IdeResult, IpcPath, RequestId};
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>> {
@@ -61,39 +58,7 @@ 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);
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);
}
result?
}
/// Installs the registry's current version of agent `id` (also to update it), streaming
@@ -107,43 +72,11 @@ 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 || {
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)
}
}
ide_agent::registry::install(&paths, &id, &cancel, &|e| {
let _ = on_event.send(e);
});
report(None, Some(1.0), true);
result
})
})
.await
.map_err(join_err);
@@ -330,20 +263,6 @@ 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();
+15 -184
View File
@@ -32,7 +32,6 @@ 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 {
@@ -46,7 +45,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
p.options = options.clone();
}
}
for g in &mut model.gradle {
if let Some(g) = &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();
}
@@ -66,12 +65,11 @@ 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);
}
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);
}
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);
}
model
}
@@ -96,89 +94,9 @@ 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,
@@ -186,7 +104,6 @@ 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)?;
@@ -205,8 +122,6 @@ 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 {
@@ -216,7 +131,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 => BTreeMap::new(),
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
};
let options = SyncOptions {
offline,
@@ -225,31 +140,14 @@ 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: None,
gradle_command: current.gradle.as_ref().map(|g| g.command.clone()),
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven => ide_build::sync_maven(&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());
}
}
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
}
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
})
@@ -257,18 +155,6 @@ 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();
@@ -361,26 +247,20 @@ 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 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 gradle = load(&root, &cache, &settings).gradle;
let options = SyncOptions {
offline,
env: gradle.env.clone(),
gradle_command: Some(gradle.command.clone()),
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -502,11 +382,6 @@ 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,
@@ -515,7 +390,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("request")?,
c.arg("offline")?,
c.arg("profiles")?,
c.arg("buildDir")?,
c.sender("onProgress")?,
)
.await
@@ -531,7 +405,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("configuration")?,
c.arg("offline")?,
c.arg("request")?,
c.arg("buildDir")?,
)
.await
});
@@ -559,48 +432,6 @@ 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();
@@ -609,13 +440,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.remove(0);
let default = load(root, &root.join("cache"), &settings).gradle.unwrap();
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.remove(0);
let model = load(root, &root.join("cache"), &settings).gradle.unwrap();
assert_eq!(
model.command,
root.join("gradle-install")
+1 -1
View File
@@ -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, JaCoCo XML or a SimpleCov resultset,
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// 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();
+4 -7
View File
@@ -123,14 +123,13 @@ 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, skip, max_rows) = (
let (request, id, statements, 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, skip, max_rows).await
execute(&c.host, &c.window, request, &id, statements, max_rows).await
});
d.add("db_table_page", |c| async move {
let (request, id, schema, table) = (
@@ -326,22 +325,20 @@ 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. Each result reads past its first `skip` rows (a later page) and keeps
/// `max_rows`. Cancel with `cancel_request(request)`.
/// error. 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_from(id, &statement.text, skip as usize, max_rows.max(1) as usize, &cancel).await {
match db.execute(id, &statement.text, 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);
-8
View File
@@ -162,14 +162,6 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
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,
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
/// editor buffer, so syntax features and language-server requests work in it.
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
+3 -10
View File
@@ -137,23 +137,16 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
if let Some(h) = history {
for p in paths {
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
}
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_workspace::file_ops::delete(&path)
ide_vfs::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
}
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
host.blocking(move || ide_vfs::copy(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
+12 -93
View File
@@ -76,17 +76,9 @@ async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static)
tokio::task::spawn_blocking(f).await.ok()
}
/// Items from plugins carry no server: commands don't apply, and a code
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
/// Items from plugins carry no server (resolve and commands don't apply).
const PLUGIN_SERVER: ServerId = ServerId(0);
/// `data` of a plugin's code action whose edits it computes when picked.
#[derive(serde::Serialize, serde::Deserialize)]
struct PluginActionRef {
plugin: String,
data: String,
}
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
Some(match kind {
@@ -128,14 +120,14 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
kind,
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
sort_text: item.sort_text,
filter_text: item.filter_text,
filter_text: None,
preselect: false,
deprecated: false,
insert_text: item.insert_text,
is_snippet: item.snippet,
is_snippet: false,
replace_range: item.replace,
replace_range_tab: None,
additional_edits: item.additional_edits,
additional_edits: Vec::new(),
commit_characters: Vec::new(),
command: None,
data: None,
@@ -260,9 +252,6 @@ pub async fn lsp_implementations(
/// 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> {
if uri.starts_with("git-revision:") {
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -325,7 +314,7 @@ pub async fn lsp_code_actions(
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(plugin, a)| plugin_code_action(&plugin, a))
.map(|(_, a)| plugin_code_action(a))
.filter(|a| {
only.is_empty()
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
@@ -341,53 +330,24 @@ pub async fn lsp_code_actions(
prepare_actions(project, doc, version, actions).await
}
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and no edits, the plugin computes them when the action is picked.
let lazy = a.edits.is_empty() && a.data.is_some();
let files: Vec<_> = a
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
let files = a
.edits
.into_iter()
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
.collect();
let data = a
.data
.filter(|_| lazy)
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
CodeAction {
title: a.title,
kind: a.kind,
is_preferred: a.preferred,
disabled_reason: None,
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
command: None,
data,
data: None,
server: PLUGIN_SERVER,
}
}
/// A code action resolved by whoever offered it: its language server, or
/// the plugin its `data` names.
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
if action.server != PLUGIN_SERVER {
return host.project(window)?.lsp.resolve_code_action(doc, action).await;
}
let Some(r) = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok()) else {
return Ok(action);
};
let (plugins, d) = plugin_providers(host, window, doc, ProviderKind::CodeActions)
.ok_or_else(|| ide_core::IdeError::unavailable("the plugin that offered this action is gone"))?;
let pending = ide_plugin::CodeAction {
title: action.title.clone(),
kind: action.kind.clone(),
preferred: action.is_preferred,
edits: Vec::new(),
data: Some(r.data),
};
let plugin = r.plugin.clone();
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
pub async fn lsp_code_action_query(
host: &Host,
@@ -421,7 +381,7 @@ pub async fn lsp_resolve_code_action(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = resolve_action(host, window, doc, action).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
}
@@ -431,7 +391,7 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let action = if action.edit.is_none() && action.command.is_none() {
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let action = resolve_action(host, window, doc, action).await?;
let action = project.lsp.resolve_code_action(doc, action).await?;
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
} else {
action
@@ -441,30 +401,12 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
let edit = edit.clone();
blocking(move || apply_edit(&documents, &edit)).await?;
}
if let Some(command) = action.command
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
{
if let Some(command) = action.command {
project.lsp.execute_command(doc, command).await?;
}
Ok(())
}
/// A command after a code action's edit that no server provides: what other editors'
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
/// is the action; there is nothing to run.
async fn is_client_follow_up(
project: &crate::project::Project,
doc: DocId,
server: Option<ide_core::ServerId>,
command: &Command,
) -> bool {
let provided = project.lsp.provides_command(doc, server, &command.command).await;
if !provided {
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
}
!provided
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -697,7 +639,6 @@ pub async fn lsp_execute_code_action(
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let edited = action.edit.is_some();
if let Some(edit) = action.edit {
let documents = project.documents.clone();
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
@@ -705,9 +646,6 @@ pub async fn lsp_execute_code_action(
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
}
match action.command {
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
Ok(Vec::new())
}
Some(command) => project
.lsp
.execute_action_command(doc, action.server, command, answers, cancel)
@@ -863,25 +801,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits,
data: data.map(str::to_owned),
};
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
assert!(lazy.edit.is_none());
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
// With edits, `data` is the plugin's business: applied as is.
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
assert!(eager.edit.is_some() && eager.data.is_none());
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
+1 -12
View File
@@ -3,7 +3,7 @@ use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
@@ -32,19 +32,10 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
/// The project's file extensions, most common first.
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
Ok(host.project(window)?.index.file_extensions())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_dirs(&query, limit as usize))
}
pub fn find_symbols(
host: &Host,
window: &str,
@@ -165,9 +156,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
});
+2 -12
View File
@@ -17,8 +17,6 @@ pub struct ProjectInfo {
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
}
pub fn info(project: &Project) -> ProjectInfo {
@@ -35,7 +33,6 @@ pub fn info(project: &Project) -> ProjectInfo {
settings,
root: project.root.clone().into(),
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
unity: ide_workspace::unity::detect(&project.root),
}
}
@@ -175,12 +172,7 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
@@ -196,9 +188,7 @@ pub fn set_settings(
}
let roots_changed = key == "projectStructure"
|| key.starts_with("projectStructure.")
|| "languages.java.maven.unlinkedProjects".starts_with(key)
|| "languages.java.maven.linkedProjects".starts_with(key)
|| "languages.java.gradle.linkedProjects".starts_with(key);
|| "languages.java.maven.unlinkedProjects".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.") {
+4 -4
View File
@@ -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: ide_core::IpcPath,
pub path: String,
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_path_buf().into(), text });
found.push(RouteSource { path: path.to_string_lossy().into_owned(), 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!(files[0].path.ends_with("src/Ctl.java"), "{:?}", files[0].path);
assert!(Path::new(&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!(files[0].path.ends_with("module/src/A.java"), "{:?}", files[0].path);
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
}
}
-19
View File
@@ -155,9 +155,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
let (root, settings) = settings_for(host, &project, &config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
// Maven home) keeps the launcher that was current then; re-resolving at launch is
// what makes the setting take effect without reopening the project.
let mut config = config;
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
@@ -198,7 +195,6 @@ 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();
@@ -215,21 +211,6 @@ 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) {
-18
View File
@@ -142,21 +142,6 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
/// the machine that runs the build, for `--init-script`, and returns its path.
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
}
tokio::task::spawn_blocking(move || {
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
@@ -323,9 +308,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_gradle_jvm_args_script", |c| async move {
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
});
d.add("run_gradle_init_script", |c| async move {
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
});
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
+2 -60
View File
@@ -23,26 +23,8 @@ 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 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)
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)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
@@ -52,42 +34,6 @@ 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 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)]
#[serde(rename_all = "camelCase")]
pub struct PhpLanguageLevels {
/// The one Intelephense checks against; `None`: its default.
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
/// The one Auto picks, from `composer.json`.
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
/// The workspace member `path` belongs to: its own settings choose the level, not the
/// workspace's. `None`: the opened project.
pub member: Option<ide_core::IpcPath>,
}
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
let project = host.project(window)?;
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
let config_dir = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
});
let (root, settings) = match &member {
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
None => (&project_root, project_settings),
};
PhpLanguageLevels {
effective: ide_sdk::php::language_level(root, &settings),
auto: ide_sdk::php::composer_level(root),
member: member.map(ide_core::IpcPath),
}
})
.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>> {
@@ -198,10 +144,6 @@ 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_language_level",
|c| async move { sdk_php_language_level(&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
+1 -5
View File
@@ -24,11 +24,7 @@ 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));
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))
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
}
/// The language definitions plus the servers defined in the window's settings.
+24 -242
View File
@@ -12,76 +12,6 @@ use std::path::{Path, PathBuf};
use crate::apply_git_executable;
/// Read-only repository access also works in a standalone log window.
struct LogAccess {
project: Option<std::sync::Arc<Project>>,
external: Option<std::sync::Arc<Repo>>,
cache: PathBuf,
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
repo: PathBuf,
revision: String,
path: PathBuf,
}
let encoded = uri
.strip_prefix("git-revision:")
.and_then(|s| s.split('/').next())
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
}
let bytes = (0..encoded.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&encoded[i..i + 2], 16)
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
})
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
uri: uri.into(),
language_id: host.languages.detect(&target.path, text.lines().next()),
text,
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
})
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
let external = host
.external_logs
.read()
.get(host.owner_window(window))
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
}
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(repo) = &self.external {
repo.enable_log_index(&self.cache);
return f(repo);
}
self.project
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
.with_log_repo_at(at, f)
}
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -252,8 +182,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
}
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -267,21 +197,9 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let project = host.project(window)?;
blocking(move || {
if let Some(project) = project {
return project.with_log_repo_of(&path, |r| {
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
});
}
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
&path,
rev.as_deref(),
encoding_rs::UTF_8,
)
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
}
@@ -304,126 +222,9 @@ pub async fn vcs_blame(
.await
}
/// History for Selection: the commits that changed `lines` (0-based, inclusive) of the
/// editor's text of `path`. Cancellable.
pub async fn vcs_block_history(
host: &Host,
window: &str,
request: RequestId,
path: PathBuf,
text: Option<String>,
lines: (u32, u32),
) -> IdeResult<BlockHistory> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = blocking(move || {
let text = text.or_else(|| project.documents.text_of(&path));
project.with_repo_of(&path, |r| {
r.block_history(&path, text.as_deref(), lines, project.vcs_encoding(&path, None)?, &cancel)
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
let project = LogAccess::new(host, window, repo.as_deref());
let request = query.request;
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);
r.log_cancellable(&query, &cancel)
})
})
.await;
if let Some(id) = request {
host.end_request(id);
}
result
}
pub async fn vcs_log_refs(
host: &Host,
window: &str,
repo: Option<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
}
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
}
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
let path = dunce::canonicalize(path)?;
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
if let Ok(project) = host.project(window) {
project.open_external_log(&root)?;
}
let mut logs = host.external_logs.write();
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
if !roots.iter().any(|r| r.root() == root) {
roots.push(std::sync::Arc::new(repo));
}
Ok(root.into())
}
pub async fn vcs_log_details(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
}
pub async fn vcs_log_details_batch(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel = host.begin_project_read_request(window, request);
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
})
})
.await;
host.end_request(request);
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
}
pub async fn vcs_revision_files(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
}
pub async fn vcs_commit_files(
@@ -432,8 +233,8 @@ pub async fn vcs_commit_files(
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
/// Combined changes of several commits, `commits` oldest first.
@@ -443,13 +244,13 @@ pub async fn vcs_commits_changes(
repo: Option<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
}
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
}
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
@@ -470,7 +271,7 @@ pub async fn vcs_add_to_ignore(
window: &str,
paths: Vec<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<Vec<ide_core::IpcPath>> {
) -> IdeResult<Vec<String>> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
@@ -487,7 +288,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(Into::into).collect())
result.map(|files| files.into_iter().map(|p| p.to_string_lossy().into_owned()).collect())
})
.await
}
@@ -804,8 +605,8 @@ pub async fn vcs_config_get(
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
/// Local history operation that may take a while (hooks, large checkouts); git's
@@ -866,8 +667,8 @@ pub async fn vcs_create_tag(
}
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
@@ -903,8 +704,8 @@ pub async fn vcs_rebase_with(
}
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -991,8 +792,8 @@ pub async fn vcs_diff_with_worktree(
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
@@ -1003,8 +804,8 @@ pub async fn vcs_diff_revisions(
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
@@ -1186,26 +987,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_blame", |c| async move {
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
});
d.add("vcs_block_history", |c| async move {
let (request, path, text, lines) = (c.arg("request")?, c.arg("path")?, c.arg("text")?, c.arg("lines")?);
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
});
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_log_details", |c| async move {
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
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_revision_children", |c| async move {
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
});
d.add("vcs_revision_files", |c| async move {
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
});
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
});
-850
View File
@@ -1,850 +0,0 @@
//! 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`.
#[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\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
sleep \"$(cat pause 2>/dev/null || echo 0)\"\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 -84
View File
@@ -2,7 +2,6 @@
//! 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;
@@ -325,18 +324,6 @@ 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());
{
@@ -420,7 +407,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: to_ipc(repo.root()),
root: repo.root().to_string_lossy().into_owned(),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
@@ -431,12 +418,6 @@ 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)) {
@@ -468,7 +449,7 @@ impl HostApi for PluginApi {
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(to_ipc(entry.path()));
out.push(entry.path().to_string_lossy().into_owned());
}
}
out.sort();
@@ -686,17 +667,6 @@ impl HostApi for PluginApi {
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
@@ -807,19 +777,6 @@ 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));
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
@@ -844,42 +801,3 @@ 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");
}
}
+1 -11
View File
@@ -422,8 +422,7 @@ struct IdeDir {
plugins: PathBuf,
}
/// 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).
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
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 {
@@ -463,8 +462,6 @@ 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
}
@@ -787,13 +784,6 @@ 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");
-10
View File
@@ -165,16 +165,6 @@ impl PluginHost {
self.save_state();
}
/// The marketplace's plugins: read again when `refresh`, else the last index
/// read. Reads it when none has been read yet (first run, nothing cached).
pub fn market_listing(&self, refresh: bool) -> IdeResult<Vec<MarketPlugin>> {
if refresh || (self.market.read().is_none() && self.market_url().is_some()) {
self.refresh_market()
} else {
Ok(self.market_plugins())
}
}
/// Reads the index again, keeps it, and applies its revocations. Announces
/// updates of installed plugins it hasn't announced yet.
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
-15
View File
@@ -292,21 +292,6 @@ impl PluginHost {
.collect()
}
/// The edits of an action `plugin_id` returned with `data`, now that the
/// user picked it: a call with the command budget, not the edit path's.
pub fn resolve_code_action(
&self,
plugin_id: &str,
doc: &ProviderDocument,
action: CodeAction,
) -> ide_core::IdeResult<CodeAction> {
let d = Arc::new(doc.clone());
let timeout = self.limits.timeout;
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
rt.resolve_code_action(&d, &action, timeout)
})
}
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
let d = Arc::new(doc.clone());
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
@@ -114,9 +114,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
)
}
-8
View File
@@ -192,17 +192,9 @@ pub enum PluginEvent {
args: Vec<String>,
cwd: ide_core::IpcPath,
},
#[serde(rename_all = "camelCase")]
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
/// 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
-10
View File
@@ -378,19 +378,9 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
}
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
out.files = files;
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
let path = std::path::PathBuf::from(icon);
let svg = super::icons::read_icon(pkg, &path)?;
out.assets.push((view_icon_asset(&d.id), svg));
out.files.push(path);
}
Ok(out)
}
pub fn view_icon_asset(id: &str) -> String {
format!("viewIcon/{id}")
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CustomEditorDecl {
+28 -313
View File
@@ -34,8 +34,6 @@ pub struct Project {
pub lsp: Arc<LspManager>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Explicitly opened external logs remain read-only and outside project indexing.
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
@@ -55,13 +53,9 @@ 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)>>,
/// Watchers of folders outside the project that language servers registered file
/// watchers for (a virtualenv elsewhere, a path dependency).
lsp_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
pub(crate) _scratch_watcher: Option<Watcher>,
/// A LightEdit window's loose files ([`Host::open_light`]): global settings only,
/// nothing indexed, no language servers, repositories or project services.
@@ -78,8 +72,6 @@ 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
@@ -91,7 +83,6 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
// Before anything the project starts: its children get the project's environment.
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
@@ -213,7 +204,6 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
@@ -228,16 +218,9 @@ impl Host {
ide_sdk::jdtls::launch_directly(cfg);
}
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
// After the overrides: the PHP version they may set decides the storage.
ide_sdk::php::configure_intelephense_storage(cfg, &data_dir);
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(),
);
}
}));
}
@@ -293,21 +276,12 @@ 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();
@@ -328,17 +302,12 @@ impl Host {
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
index.update(&changed, &batch.removed);
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)) {
lsp.reconfigure_soon();
}
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed);
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&[&changed[..], &ignored].concat());
documents.on_disk_changed(&changed);
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
@@ -442,17 +411,7 @@ impl Host {
let content_watchers = extra_roots
.iter()
.filter_map(|r| {
let w = content_root_watcher(
r,
&excludes,
&sink,
&index,
&documents,
&history,
&lsp,
Some(&kotlin_bridge),
Some(&phpstan),
)?;
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
Some((r.clone(), w))
})
.collect();
@@ -485,7 +444,6 @@ impl Host {
index,
lsp,
repos,
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
@@ -498,10 +456,8 @@ 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()),
_scratch_watcher: scratch_watcher,
light: false,
});
@@ -511,14 +467,6 @@ impl Host {
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.lsp.set_file_watch_roots_hook(Box::new(move |roots| {
if let Some(p) = weak.upgrade() {
p.watch_for_language_servers(roots);
}
}));
}
{
let weak = Arc::downgrade(&project);
project.lsp.set_project_excludes_hook(Box::new(move || {
@@ -529,9 +477,6 @@ impl Host {
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());
}
@@ -544,15 +489,12 @@ impl Host {
root: &Path,
project_key: &str,
) -> (Arc<ide_db::DbManager>, Arc<ide_http::History>, Arc<ide_agent::AgentManager>) {
let db = Arc::new(
ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
)
.with_global_sources(self.data_dir.clone()),
);
let db = Arc::new(ide_db::DbManager::new(
root.to_path_buf(),
self.data_dir.join("db-passwords.json"),
self.data_dir.join("db-history.json"),
self.ssh.clone(),
));
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key)));
let agents = Arc::new(ide_agent::AgentManager::new(
ide_agent::AgentPaths {
@@ -587,8 +529,6 @@ 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
{
@@ -596,7 +536,6 @@ impl Host {
return Ok(current);
}
self.close_project(window);
self.wait_detached_closes();
let sink: SharedSink = (self.sink_for)(window);
let settings = Arc::new(SettingsStore::load(&self.config_dir, None));
let effective = settings.effective();
@@ -629,7 +568,6 @@ impl Host {
index,
lsp,
repos: Arc::default(),
external_log_repos: RwLock::default(),
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
@@ -642,10 +580,8 @@ impl Host {
agents,
notebooks,
kotlin_bridge: None,
phpstan: None,
watcher,
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
light: true,
root: dir,
@@ -728,14 +664,7 @@ fn scan_repositories(
build_cache: &Path,
) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| {
p.file_name().is_some_and(|n| {
let name = n.to_string_lossy();
ignored.is_ignored_name(&name)
|| (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref())
&& p.parent().is_some_and(ide_workspace::unity::is_project))
})
};
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
let mut roots = vec![root.to_path_buf()];
roots.extend(content_roots(&settings.effective(), root, build_cache));
match Repos::discover_roots(&roots, &skip) {
@@ -782,51 +711,6 @@ impl Project {
}
}
pub fn open_external_log(&self, path: &Path) -> IdeResult<PathBuf> {
let root = dunce::canonicalize(path)?;
let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?;
let root = repo.root().to_path_buf();
repo.set_execution_allowed(false);
let mut external = self.external_log_repos.write();
if !external.iter().any(|r| r.root() == root) {
external.push(Arc::new(repo));
}
Ok(root)
}
/// Log read operations may also use an explicitly opened external worktree.
/// Mutations continue to use `with_repo_at` and cannot reach these repositories.
pub fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if let Some(path) = at
&& self.repos().at_root(path).is_none()
{
let repo = self
.external_log_repos
.read()
.iter()
.find(|r| r.root() == path)
.cloned()
.ok_or_else(|| IdeError::unavailable("external Git log is not open"))?;
return f(&repo);
}
self.with_repo_at(at, f)
}
pub fn with_log_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
if self.repos().for_path(path).is_some() {
return self.with_repo_of(path, f);
}
let repo = self
.external_log_repos
.read()
.iter()
.filter(|r| path.starts_with(r.root()))
.max_by_key(|r| r.root().components().count())
.cloned()
.ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?;
f(&repo)
}
/// Runs `f` with the repository `path` (a file or folder) belongs to.
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
match self.repos().for_path(path) {
@@ -902,14 +786,8 @@ impl Project {
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
pub fn roots(&self) -> ProjectRoots {
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)
let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read());
linked_roots(&self.root, &self.settings.effective(), &detected)
}
/// Where the Maven / Gradle tool windows cache what the build tools reported.
@@ -938,8 +816,7 @@ impl Project {
/// Project files of `q.languages` under `q.root` containing `q.word` as a whole
/// word (unsaved buffers included; excluded roots, ignored names and
/// `.gitignore`d folders skipped, while `.gitignore`d files elsewhere count, as
/// in IDEA's project scope): the files a references search opens on its server.
/// `.gitignore`d files skipped): the files a references search opens on its server.
pub fn reference_candidates(
&self,
q: &ide_lsp::CandidateQuery,
@@ -1017,9 +894,7 @@ impl Project {
&self.index,
&self.documents,
&self.history,
&self.lsp,
self.kotlin_bridge.as_ref(),
self.phpstan.as_ref(),
) {
watchers.push((r, w));
}
@@ -1050,7 +925,7 @@ impl Project {
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
}
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
Ok(ide_vfs::detect_encoding(&bytes, fallback))
}
@@ -1073,11 +948,6 @@ const HISTORY_BATCH_LIMIT: usize = 500;
/// Local History for changes made outside the IDE (other tools, VCS, deletions).
/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped.
/// The batch's ignored files (`.env`), split into those still on disk and those removed.
fn ignored_changes(batch: &ide_vfs::FsBatch) -> (Vec<PathBuf>, Vec<PathBuf>) {
batch.ignored.iter().cloned().partition(|p| p.symlink_metadata().is_ok())
}
fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) {
for path in changed.iter().take(HISTORY_BATCH_LIMIT) {
if let Err(e) = history.record_file(path, "External change", false) {
@@ -1111,7 +981,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, &settings.languages.java.gradle)) {
for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) {
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
out.push(p);
}
@@ -1121,31 +991,6 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// Watches a content root outside the project: the index, open documents and
/// Local History follow its changes like the project folder's.
impl Project {
/// Watches `roots` (outside the project) for the language servers that asked to.
fn watch_for_language_servers(&self, roots: Vec<PathBuf>) {
let mut watchers = self.lsp_watchers.lock();
watchers.retain(|(r, _)| roots.contains(r));
for root in roots {
if watchers.iter().any(|(r, _)| *r == root) || !root.is_dir() {
continue;
}
let lsp = Arc::downgrade(&self.lsp);
let on_change = Box::new(move |batch: &ide_vfs::FsBatch| {
if let Some(lsp) = lsp.upgrade() {
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs);
}
});
// Not project files: nothing else hears of them.
match Watcher::start(&root, Vec::new(), Arc::new(ide_core::NullSink), on_change) {
Ok(w) => watchers.push((root, w)),
Err(e) => tracing::warn!("language server watcher {}: {e}", root.display()),
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn content_root_watcher(
dir: &Path,
excludes: &[String],
@@ -1153,32 +998,21 @@ fn content_root_watcher(
index: &Arc<ProjectIndex>,
documents: &Arc<Documents>,
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, 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();
let kotlin_bridge = kotlin_bridge.cloned();
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
Watcher::start(
dir,
excludes.to_vec(),
sink.clone(),
Box::new(move |batch| {
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
let (ignored, ignored_gone) = ignored_changes(batch);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
// A workspace member's PHP language level follows its own composer.json.
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
lsp.reconfigure_soon();
}
index.update(&changed, &batch.removed);
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());
documents.on_disk_changed(&changed);
record_external_changes(&history, &changed, &batch.removed);
}),
)
@@ -1186,13 +1020,6 @@ fn content_root_watcher(
.ok()
}
/// Whether the `composer.json` of `root` is among `paths`: the PHP language level follows it
/// (`ide_sdk::php`).
fn composer_changed<'a>(root: &Path, mut paths: impl Iterator<Item = &'a PathBuf>) -> bool {
let composer = root.join("composer.json");
paths.any(|p| *p == composer)
}
/// Where agent tasks create their worktrees: next to the project, `<parent>/<name>.worktrees`,
/// so they sit on the project's drive and outside its tree (not indexed, watched or shown in
/// its git status); `fallback` for a project at a filesystem root.
@@ -1219,93 +1046,31 @@ pub(crate) fn jdtls_maven_import(
settings: &ide_core::settings::Settings,
) -> (Vec<PathBuf>, Option<Vec<String>>) {
let maven = &settings.languages.java.maven;
let gradle = &settings.languages.java.gradle;
if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() {
if maven.unlinked_projects.is_empty() {
return (Vec::new(), None);
}
let model = ide_build::maven::load_linked(root, maven);
let model = ide_build::maven::load(root);
let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven);
let excluded = unlinked.poms().map(PathBuf::from).collect();
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 build_files = unlinked.import_poms(&model).map(|poms| {
let gradle = ide_build::gradle::load(root).and_then(|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())
});
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()
poms.iter().map(|pom| root.join(pom)).chain(gradle).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,
settings: &ide_core::settings::GradleSettings,
detected: &[SourceFolder],
) -> Vec<SourceFolder> {
fn with_gradle_roots(root: &Path, build_cache: &Path, 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(root, build_cache, settings) {
for (dir, kind) in ide_build::gradle_source_roots(build_cache) {
if !dir.is_dir() {
continue;
}
@@ -1475,37 +1240,6 @@ 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();
@@ -1603,8 +1337,7 @@ 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 };
std::fs::write(root.join("build.gradle"), "").unwrap();
let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]);
let out = with_gradle_roots(&root, &cache, &[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!(
@@ -1703,21 +1436,3 @@ mod agent_worktree_tests {
assert_eq!(agent_worktrees_dir(Path::new("/"), fallback), fallback());
}
}
#[cfg(test)]
mod composer_tests {
use super::composer_changed;
use std::path::{Path, PathBuf};
#[test]
fn only_the_roots_own_composer_json_counts() {
let root = Path::new("/work/app");
let changed = |paths: &[&str]| {
let paths: Vec<PathBuf> = paths.iter().map(|p| root.join(p)).collect();
composer_changed(root, paths.iter())
};
assert!(changed(&["src/a.php", "composer.json"]));
assert!(!changed(&["composer.lock", "vendor/acme/lib/composer.json", "packages/b/composer.json"]));
assert!(!changed(&[]));
}
}
+4 -66
View File
@@ -1,7 +1,6 @@
//! System Settings › HTTP Proxy on the host: the global settings made current for
//! connections and child processes, the password kept in the saved passwords
//! (the OS credential store) instead of `settings.toml`, or for the session
//! when there is no credential store.
//! (owner-only `proxy-password.json`) instead of `settings.toml`.
use crate::Host;
use ide_core::IdeResult;
@@ -13,9 +12,7 @@ impl Host {
/// `settings` with the saved proxy password filled in, as the Settings dialog edits it.
pub fn with_proxy_password(&self, mut settings: Settings) -> Settings {
// A password typed into settings.toml by hand counts too, unless one was saved.
let session = || self.proxy_session_password.lock().clone();
// The session copy is the latest write that could not be saved.
if let Some(password) = session().or_else(|| self.proxy_password.get(PASSWORD_KEY)) {
if let Some(password) = self.proxy_password.get(PASSWORD_KEY) {
settings.proxy.password = password;
}
settings
@@ -43,70 +40,11 @@ impl Host {
_ => return Ok(Some(value)),
};
if let Some(password) = password {
let password = password.as_str().filter(|p| !p.is_empty());
let mut session = self.proxy_session_password.lock();
*session = None;
match password {
Some(p) => {
if let Err(e) = self.proxy_password.set(PASSWORD_KEY, p) {
tracing::warn!("the HTTP proxy password is kept for this session only: {}", e.message);
*session = Some(p.to_owned());
}
}
match password.as_str().filter(|p| !p.is_empty()) {
Some(p) => self.proxy_password.set(PASSWORD_KEY, p)?,
None => self.proxy_password.delete(PASSWORD_KEY),
}
}
Ok((key != "proxy.password").then_some(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_ssh::secrets::{SecretFile, StoreError, StoreResult, Vault};
use std::sync::Arc;
/// A machine without a credential store (a headless Linux host).
struct NoStore;
impl Vault for NoStore {
fn available(&self) -> bool {
false
}
fn get(&self, _: &str, _: &str) -> StoreResult<String> {
Err(StoreError::NoDefaultStore)
}
fn set(&self, _: &str, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
fn delete(&self, _: &str, _: &str) -> StoreResult<()> {
Err(StoreError::NoDefaultStore)
}
}
#[tokio::test]
async fn without_a_credential_store_the_password_lasts_the_session() {
let tmp = tempfile::tempdir().unwrap();
let mut host = Host::new(crate::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(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let path = tmp.path().join("data/proxy-password.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{"httpProxy": "old"}"#).unwrap();
host.proxy_password = SecretFile::with_vault(path, |v| serde_json::from_value(v).ok(), Arc::new(NoStore));
let left = host.take_proxy_password("proxy.password", serde_json::json!("s3cret")).unwrap();
assert_eq!(left, None);
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "s3cret");
host.take_proxy_password("proxy.password", serde_json::json!("")).unwrap();
assert_eq!(host.with_proxy_password(Settings::default()).proxy.password, "");
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ impl Dispatcher {
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
})
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
-17
View File
@@ -109,9 +109,6 @@ 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);
}
@@ -220,19 +217,15 @@ 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"
| "project_roots"
| "index_status"
| "index_file_extensions"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_dirs"
| "find_symbols"
| "mark_recent"
| "remove_recent"
@@ -254,8 +247,6 @@ 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"
@@ -313,15 +304,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_line_hunks"
| "vcs_file_at"
| "vcs_blame"
| "vcs_block_history"
| "vcs_log"
| "vcs_log_refs"
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
| "vcs_commit_files"
| "vcs_commits_changes"
| "vcs_branches"
-51
View File
@@ -5,7 +5,6 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
use std::time::{Duration, Instant};
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
@@ -53,53 +52,3 @@ fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
host.terminals.kill_key("other");
});
}
/// The desktop closes a project on the event-loop thread while runtime workers may be
/// blocked waiting for that thread (IPC responses, window queries): closing must not
/// wait for workers that need the runtime, and exit still waits for them once free.
#[test]
fn detached_close_returns_while_runtime_workers_wait_for_the_closing_thread() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(1).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();
host.open_project("main", &root).unwrap();
// Occupy the only runtime worker until "the event loop" answers.
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
runtime.spawn(async move {
started_tx.send(()).unwrap();
let _ = release_rx.recv();
});
started_rx.recv().unwrap();
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
let closer = host.clone();
std::thread::spawn(move || {
closer.close_project_detached("main");
let _ = closed_tx.send(());
});
let returned = closed_rx.recv_timeout(Duration::from_secs(5));
release_tx.send(()).unwrap();
assert!(returned.is_ok(), "closing waited for a runtime worker that waits for the closing thread");
assert!(host.project("main").is_err(), "the window no longer has the project");
host.open_project("main", &root).expect("the window opens a project once the close has finished");
let started = Instant::now();
host.shutdown_notebooks_on_exit();
assert!(started.elapsed() < Duration::from_secs(5), "exit joined the finished close instead of timing out");
}
-22
View File
@@ -196,28 +196,6 @@ fn write_index(
path.to_string_lossy().into_owned()
}
/// First run: nothing cached yet, so listing the marketplace reads its index
/// instead of showing an empty one; later listings use the index read.
#[test]
fn the_first_marketplace_listing_reads_the_index() {
let f = fixture();
assert!(f.host.market_listing(false).unwrap().is_empty(), "no marketplace configured");
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let url = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(url.clone()));
let listed = f.host.market_listing(false).unwrap();
assert_eq!(listed.iter().map(|p| p.entry.id.as_str()).collect::<Vec<_>>(), ["acme.hello"]);
std::fs::remove_file(&url).unwrap();
assert_eq!(f.host.market_listing(false).unwrap().len(), 1, "the index read is kept");
assert!(f.host.market_listing(true).is_err(), "refresh reads the index again");
}
#[test]
fn signed_marketplace_plugins_install_and_revocations_disable_them() {
let f = fixture();
-115
View File
@@ -1,115 +0,0 @@
//! Explicit external logs allow history reads without adding a writable project root.
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc};
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(root)
.args(["-c", "user.name=Test", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_history_is_readable_and_never_becomes_a_write_target() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
let external = base.join("external");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&external).unwrap();
git(&external, &["init", "-b", "main"]);
let deleted = external.join("deleted.txt");
std::fs::write(&deleted, "historical content\n").unwrap();
git(&external, &["add", "."]);
git(&external, &["commit", "-m", "initial"]);
let first = git(&external, &["rev-parse", "HEAD"]);
git(&external, &["rm", "deleted.txt"]);
git(&external, &["commit", "-m", "delete"]);
let head = git(&external, &["rev-parse", "HEAD"]);
let host = 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,
});
// A standalone editor needs no project, indexer, or writable VCS root.
vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap();
let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(standalone[0].id, head);
let details = vcs::vcs_log_details_batch(
&host,
"standalone",
Some(external.clone()),
vec![head.clone(), first.clone()],
ide_core::RequestId(987),
)
.await
.unwrap();
assert!(details.iter().all(|d| d.signature == "N"));
assert_eq!(
vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new())
.await
.unwrap(),
vec!["deleted.txt"]
);
assert!(host.project("standalone").is_err());
host.trust.decide(&external, false, false).unwrap();
let restricted = host.open_project("restricted", &external).unwrap();
assert!(!restricted.is_trusted());
vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap();
let restricted_log = vcs::vcs_log(
&host,
"restricted",
Some(external.clone()),
ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() },
)
.await
.unwrap();
assert_eq!(restricted_log[0].id, head);
assert_eq!(
vcs::vcs_log_details_batch(
&host,
"restricted",
Some(external.clone()),
vec![head.clone()],
ide_core::RequestId(989)
)
.await
.unwrap()[0]
.signature,
"N"
);
host.close_project("restricted");
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap();
let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap();
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![head.as_str(), first.as_str()]);
assert_eq!(
vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(),
Some("historical content\n")
);
assert!(project.repos().at_root(&external).is_none());
assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err());
assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err());
assert_eq!(git(&external, &["rev-parse", "HEAD"]), head);
assert!(!deleted.exists());
host.close_project("main");
}
-49
View File
@@ -1,49 +0,0 @@
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;
}
-994
View File
@@ -1,994 +0,0 @@
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
//! language server the app starts by default.
//!
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
//! pinned versions and runs one language per job:
//! `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>`.
use ide_core::{ChangeSet, DocId, IdeEvent, Pos, Range, RequestId, TextChange};
use ide_host::{Host, HostConfig, ops};
use ide_lsp::types::{FormatOptions, GotoKind};
use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
/// `word` on the first line of `file` that contains `line`.
#[derive(Clone, Copy, Debug)]
struct At {
file: &'static str,
line: &'static str,
word: &'static str,
}
const fn at(file: &'static str, line: &'static str, word: &'static str) -> At {
At { file, line, word }
}
/// A line typed after the first line of `file` containing `after`.
#[derive(Clone, Copy, Debug)]
struct Typed {
file: &'static str,
after: &'static str,
text: &'static str,
}
const fn typed(file: &'static str, after: &'static str, text: &'static str) -> Typed {
Typed { file, after, text }
}
/// One language's checklist. Every check names what it looks for, so a failure says which
/// operation broke.
struct Checks {
fixture: &'static str,
/// Files written into the project copy before it opens.
files: &'static [(&'static str, &'static str)],
/// How long the first answer may take (project import and indexing).
warm_up: Duration,
/// Go to definition from the first spot lands on the second.
definition: (At, At),
/// Find Usages of the symbol lists usages in these files.
references: (At, &'static [&'static str]),
/// Hover shows this text.
hover: (At, &'static str),
/// Completion at the end of the typed line offers this item.
completion: (Typed, &'static str),
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
/// file contain the last text.
quick_fix: QuickFix,
/// With the word selected, this intention is offered with an edit or command.
intention: (At, &'static str),
/// Formatting turns the typed line into the expected one.
format: (Typed, &'static str),
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
outline: (&'static str, &'static [&'static str]),
/// Completing a name the file doesn't import yet also imports it.
auto_import: AutoImport,
/// Optimize Imports, with an import typed after a line.
organize_imports: Option<(Typed, Imports)>,
/// Moving a file updates its references: (file, new path, a file that then contains this).
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
}
#[derive(Clone, Copy, Debug)]
struct AutoImport {
/// The line typed, ending in the start of the name.
typed: Typed,
/// The completion item.
label: &'static str,
/// What the item's import adds to the file.
result: &'static str,
}
/// What Optimize Imports does with the typed import.
#[derive(Clone, Copy, Debug)]
enum Imports {
/// Removes it (unused).
Removed,
/// Sorts it before this line.
Before(&'static str),
}
#[derive(Clone, Copy, Debug)]
struct QuickFix {
typed: Typed,
diagnostic: &'static str,
title: &'static str,
result: &'static str,
}
/// The editor's indentation, sent with a format request.
#[derive(Clone, Copy, Debug)]
enum Indent {
Spaces(u32),
Tabs,
}
type Events = Arc<Mutex<Vec<IdeEvent>>>;
struct Smoke {
language: &'static str,
host: Arc<Host>,
root: PathBuf,
events: Events,
warm_up: Duration,
_tmp: tempfile::TempDir,
}
fn fixture_dir(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(name)
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
continue;
}
let target = to.join(&name);
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &target);
} else {
std::fs::copy(entry.path(), target).unwrap();
}
}
}
fn utf16_len(s: &str) -> u32 {
s.encode_utf16().count() as u32
}
/// The 0-based line index and text of the first line containing `needle`.
fn find_line<'a>(text: &'a str, needle: &str, what: &str) -> (u32, &'a str) {
text.lines()
.enumerate()
.find(|(_, l)| l.contains(needle))
.map(|(i, l)| (i as u32, l))
.unwrap_or_else(|| panic!("{what}: no line containing {needle:?}"))
}
/// The byte offset of `word` in `line`, not as part of a longer identifier (`parse` in `parser::parse`).
fn find_word(line: &str, word: &str) -> Option<usize> {
let ident = |c: char| c.is_alphanumeric() || c == '_';
line.match_indices(word).map(|(i, _)| i).find(|&i| {
let before = line[..i].chars().next_back();
let after = line[i + word.len()..].chars().next();
!(word.starts_with(ident) && before.is_some_and(ident)) && !(word.ends_with(ident) && after.is_some_and(ident))
})
}
impl Smoke {
async fn start(language: &'static str, checks: &Checks) -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(checks.fixture);
copy_dir(&fixture_dir(checks.fixture), &root);
for (path, text) in checks.files {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let events: Events = Arc::default();
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new({
let events = events.clone();
move |_| {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().unwrap().push(e))
}
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project(WINDOW, &root).unwrap();
Smoke { language, host, root, events, warm_up: checks.warm_up, _tmp: tmp }
}
fn fail(&self, check: &str, message: impl std::fmt::Display) -> ! {
let servers = ops::lsp::lsp_servers(&self.host, WINDOW).unwrap_or_default();
let servers: Vec<String> = servers.iter().map(|s| format!("{} ({:?})", s.name, s.status)).collect();
panic!("{} — {check}: {message}\nservers: {servers:?}", self.language)
}
/// Retries `f` until it succeeds (servers answer emptily while importing and indexing); on
/// timeout, fails with its last answer.
async fn retry<T, D: std::fmt::Debug, F: Future<Output = Result<T, D>>>(
&self,
check: &str,
timeout: Duration,
mut f: impl FnMut() -> F,
) -> T {
let deadline = Instant::now() + timeout;
loop {
let last = match f().await {
Ok(v) => return v,
Err(last) => last,
};
if Instant::now() > deadline {
self.fail(check, format_args!("timed out after {timeout:?}; last answer: {last:?}"));
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn doc(&self, file: &str) -> (DocId, String) {
let info = ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap();
let info = ops::doc::snapshot(&self.host, WINDOW, info.id).await.unwrap();
(info.id, info.text)
}
async fn locate(&self, at: At) -> (DocId, Pos) {
let (doc, text) = self.doc(at.file).await;
let (line, content) = find_line(&text, at.line, at.file);
let col = find_word(content, at.word).unwrap_or_else(|| panic!("{at:?}: word not on the line"));
(doc, Pos::new(line, utf16_len(&content[..col])))
}
async fn word_range(&self, at: At) -> (DocId, Range) {
let (doc, start) = self.locate(at).await;
(doc, Range::new(start, Pos::new(start.line, start.col + utf16_len(at.word))))
}
/// Types a new line; returns the position at its end and the text before it.
async fn type_line(&self, t: Typed) -> (DocId, Pos, String) {
let (doc, text) = self.doc(t.file).await;
let (line, content) = find_line(&text, t.after, t.file);
let line_start: usize = text.lines().take(line as usize).map(|l| l.len() + 1).sum();
let end = utf16_len(&text[..line_start + content.len()]);
self.replace(doc, end, end, &format!("\n{}", t.text)).await;
let col = utf16_len(t.text);
(doc, Pos::new(line + 1, col), text)
}
async fn replace(&self, doc: DocId, from: u32, to: u32, insert: &str) {
let version = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().version;
let changes = ChangeSet { changes: vec![TextChange { from, to, insert: insert.into() }] };
ops::doc::edit(&self.host, WINDOW, doc, version, changes).unwrap();
}
async fn restore(&self, doc: DocId, text: &str) {
let current = ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text;
self.replace(doc, 0, utf16_len(&current), text).await;
}
fn path(&self, file: &str) -> PathBuf {
self.root.join(file)
}
fn files<P: AsRef<Path>>(&self, paths: impl IntoIterator<Item = P>) -> BTreeSet<String> {
paths
.into_iter()
.filter_map(|p| {
let p = p.as_ref().strip_prefix(&self.root).ok()?;
Some(p.to_string_lossy().replace('\\', "/"))
})
.collect()
}
async fn definition(&self, from: At, to: At) {
let (doc, pos) = self.locate(from).await;
let (_, target) = self.locate(to).await;
let want = self.path(to.file);
self.retry("go to definition", self.warm_up, || async {
let found = ops::lsp::lsp_goto(&self.host, WINDOW, doc, pos, GotoKind::Definition).await;
match &found {
Ok(locs) if locs.iter().any(|l| *l.path == *want && l.range.start.line == target.line) => Ok(()),
_ => Err(found),
}
})
.await;
}
async fn references(&self, of: At, files: &[&str]) {
let (doc, pos) = self.locate(of).await;
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
self.retry("find usages", self.warm_up, || async {
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await;
match &found {
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
}
})
.await;
}
async fn hover(&self, over: At, shows: &str) {
let (doc, pos) = self.locate(over).await;
self.retry("hover", self.warm_up, || async {
let found = ops::lsp::lsp_hover(&self.host, WINDOW, RequestId(2), doc, pos).await;
match &found {
Ok(Some(h)) if h.contents.value.contains(shows) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.contents.value))),
}
})
.await;
}
async fn completion(&self, line: Typed, offers: &str) {
let (doc, pos, before) = self.type_line(line).await;
let trigger = line.text.ends_with('.').then(|| ".".to_string());
self.retry("completion", self.warm_up, || async {
let found = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(3), doc, pos, trigger.clone()).await;
match &found {
Ok(list)
if list.items.iter().any(|i| i.label == offers || i.filter_text.as_deref() == Some(offers)) =>
{
Ok(())
}
_ => Err(found.map(|l| l.items.into_iter().take(30).map(|i| i.label).collect::<Vec<_>>())),
}
})
.await;
self.restore(doc, &before).await;
}
async fn diagnostic(&self, file: &str, message: &str) -> Range {
let path = self.path(file);
self.retry("diagnostics", self.warm_up, || async {
let events = self.events.lock().unwrap();
let latest = events.iter().rev().filter_map(|e| match e {
IdeEvent::Diagnostics { path: p, items, source } if **p == *path => Some((source, items)),
_ => None,
});
let mut seen = Vec::new();
for (source, items) in latest {
if let Some(d) = items.iter().find(|d| d.message.contains(message)) {
return Ok(d.range);
}
seen.push(format!("{source}: {:?}", items.iter().map(|d| &d.message).collect::<Vec<_>>()));
}
Err(seen.into_iter().take(4).collect::<Vec<_>>())
})
.await
}
/// The code actions over `range` (all kinds, as the ⌥⏎ popup asks), the one titled `title`.
async fn action(&self, check: &str, doc: DocId, range: Range, title: &str) -> ide_lsp::types::CodeAction {
let action = self
.retry(check, self.warm_up, || async {
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, range, vec![]).await;
match found.as_ref().ok().and_then(|a| a.iter().find(|a| a.title.contains(title))) {
Some(action) => Ok(action.clone()),
None => Err(found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())),
}
})
.await;
if action.edit.is_some() || action.command.is_some() {
return action;
}
ops::lsp::lsp_resolve_code_action(&self.host, WINDOW, doc, action)
.await
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
}
async fn quick_fix(&self, fix: QuickFix) {
let (doc, _, before) = self.type_line(fix.typed).await;
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
let action = self.action("quick fix", doc, range, fix.title).await;
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
.await
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
self.retry("quick fix", Duration::from_secs(10), || async {
let text = self.doc(fix.typed.file).await.1;
if text.contains(fix.result) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn intention(&self, on: At, title: &str) {
let (doc, range) = self.word_range(on).await;
let action = self.action("intention", doc, range, title).await;
let edits = action.edit.as_ref().is_some_and(|e| !e.files.is_empty());
if !edits && action.command.is_none() {
self.fail("intention", format_args!("{title:?} has neither an edit nor a command: {action:?}"));
}
}
async fn format(&self, line: Typed, indent: Indent, formatted: &str) {
let (doc, _, before) = self.type_line(line).await;
assert!(!before.lines().any(|l| l == formatted), "{line:?}: the file already has {formatted:?}");
let (tab_size, insert_spaces) = match indent {
Indent::Spaces(n) => (n, true),
Indent::Tabs => (4, false),
};
let options = FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
let text = self
.retry("format", self.warm_up, || async {
let edits = ops::lsp::lsp_format(&self.host, WINDOW, doc, None, options.clone()).await;
let text = self.doc(line.file).await.1;
let mut document = ide_text::Document::new(&text);
match &edits {
Ok(edits) if !edits.is_empty() && document.apply_edits(edits).is_ok() => Ok(document.text()),
_ => Err(edits),
}
})
.await;
if !text.lines().any(|l| l == formatted) {
self.fail("format", format_args!("no line {formatted:?} in:\n{text}"));
}
self.restore(doc, &before).await;
}
async fn rename(&self, symbol: At, to: &str, files: &[&str]) {
let (doc, pos) = self.locate(symbol).await;
let prepared = ops::lsp::lsp_prepare_rename(&self.host, WINDOW, doc, pos).await;
if !matches!(prepared, Ok(Some(_))) {
self.fail("rename", format_args!("prepare rename: {prepared:?}"));
}
let renamed = ops::lsp::lsp_rename(&self.host, WINDOW, RequestId(5), doc, pos, to.into())
.await
.unwrap_or_else(|e| self.fail("rename", e));
let edited = self.files(renamed.edit.files.iter().filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(path.clone()),
_ => None,
}));
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
if !want.is_subset(&edited) {
self.fail("rename", format_args!("edits {edited:?}, expected at least {want:?}: {:?}", renamed.edit));
}
let mut before = Vec::new();
for file in files {
before.push(self.doc(file).await.1.matches(to).count());
}
ops::lsp::apply_workspace_edit(&self.host, WINDOW, renamed.edit)
.await
.unwrap_or_else(|e| self.fail("rename", format_args!("applying the edit: {e}")));
for (file, before) in files.iter().zip(before) {
if self.doc(file).await.1.matches(to).count() <= before {
self.fail("rename", format_args!("{file} has no new {to:?} after the rename"));
}
}
}
async fn signature(&self, call: Typed, parameter: &str) {
let (doc, pos, before) = self.type_line(call).await;
self.retry("parameter info", self.warm_up, || async {
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
match &found {
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
}
})
.await;
self.restore(doc, &before).await;
}
async fn outline(&self, file: &str, symbols: &[&str]) {
let (doc, _) = self.doc(file).await;
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
for item in items {
out.insert(item.name.clone());
names(&item.children, out);
}
}
self.retry("structure", self.warm_up, || async {
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
let mut shown = BTreeSet::new();
if let Ok(items) = &found {
names(items, &mut shown);
}
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
})
.await;
}
async fn auto_import(&self, a: AutoImport) {
let (doc, pos, before) = self.type_line(a.typed).await;
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
self.retry("auto-import completion", self.warm_up, || async {
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
let items = match &list {
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
Err(e) => return Err(format!("{e}")),
};
let text = self.doc(a.typed.file).await.1;
let mut imports = Vec::new();
for item in items {
// Servers compute the import when the item is resolved (on selection).
let item = if item.additional_edits.is_empty() {
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
} else {
item
};
let mut document = ide_text::Document::new(&text);
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
return Ok(());
}
// Or accepting it runs a server command that edits the file (kotlin-lsp).
if let Some(command) = item.command.clone() {
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
let text = self.doc(a.typed.file).await.1;
if ran.is_ok() && text.contains(a.result) {
return Ok(());
}
}
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
}
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
})
.await;
self.restore(doc, &before).await;
}
async fn organize_imports(&self, import: Typed, expect: Imports) {
let (doc, _, before) = self.type_line(import).await;
let typed = import.text.trim();
let organized = |text: &str| match expect {
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
Imports::Before(line) => {
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
}
};
self.retry("optimize imports", self.warm_up, || async {
let text = self.doc(import.file).await.1;
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
let last = text.split('\n').count() as u32 - 1;
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
let kind = "source.organizeImports".to_string();
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
let actions: Vec<_> = match &found {
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
Err(e) => return Err(format!("{e}")),
};
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
return Err(format!(
"no {kind} action: {:?}",
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
));
};
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
let text = self.doc(import.file).await.1;
if organized(&text) { Ok(()) } else { Err(text) }
})
.await;
self.restore(doc, &before).await;
}
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
// The servers know the referencing file (opened, as in an editor).
let _ = self.doc(referencing).await;
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
let edit = self
.retry("move file", self.warm_up, || async {
let found =
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
let want = self.path(referencing);
match &found {
Ok(e)
if e.edit
.files
.iter()
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
{
Ok(e.edit.clone())
}
_ => Err(found),
}
})
.await;
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
.await
.unwrap_or_else(|e| self.fail("move file", e));
if !self.path(to).is_file() {
self.fail("move file", format_args!("{to} doesn't exist after the move"));
}
let text = self.doc(referencing).await.1;
if !text.contains(result) {
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
}
}
async fn run(&self, c: &Checks) {
self.definition(c.definition.0, c.definition.1).await;
self.references(c.references.0, c.references.1).await;
self.hover(c.hover.0, c.hover.1).await;
self.completion(c.completion.0, c.completion.1).await;
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
self.auto_import(c.auto_import).await;
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
}
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
}
}
async fn smoke(language: &'static str, checks: Checks) {
let smoke = Smoke::start(language, &checks).await;
smoke.run(&checks).await;
}
const TYPESCRIPT: Checks = Checks {
fixture: "ts-app",
files: &[],
warm_up: Duration::from_secs(120),
definition: (
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
at("src/money.ts", "export function formatMoney", "formatMoney"),
),
references: (at("src/money.ts", "export function formatMoney", "formatMoney"), &["src/index.ts", "src/money.ts"]),
hover: (at("src/index.ts", "price: cents(8999)", "cents"), "cents(value: number): Cents"),
completion: (typed("src/index.ts", "const total = cart.total();", " cart."), "applyDiscount"),
quick_fix: QuickFix {
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
diagnostic: "Cannot find name 'addMoney'",
title: "import from \"./money.ts\"",
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
},
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
format: (
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
" const shown = { total, count: cart.count }",
),
indent: Indent::Spaces(2),
rename: (
// From a usage in another file: the declaration is renamed too, not aliased in the import.
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
label: "addMoney",
result: "addMoney",
},
organize_imports: Some((
typed(
"src/index.ts",
"import { cents, formatMoney } from \"./money.ts\";",
"import { readFileSync } from \"node:fs\";",
),
Imports::Removed,
)),
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs vtsls"]
async fn typescript() {
smoke("typescript", TYPESCRIPT).await;
}
const PYTHON: Checks = Checks {
fixture: "python-app",
// Ruff (lint, quick fixes, formatting) starts in projects configured for it.
files: &[("ruff.toml", "")],
warm_up: Duration::from_secs(120),
definition: (
at("src/tasks/cli.py", "tasks = TaskList()", "TaskList"),
at("src/tasks/models.py", "class TaskList:", "TaskList"),
),
references: (
at("src/tasks/models.py", "class TaskList:", "TaskList"),
&["src/tasks/cli.py", "src/tasks/models.py"],
),
hover: (at("src/tasks/cli.py", "tasks.add(\"Write release notes\"", "add"), "def add("),
completion: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks."), "pending"),
quick_fix: QuickFix {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
diagnostic: "\"Task\" is not defined",
title: "from .models import Task",
result: "from .models import Priority, Task, TaskList",
},
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
format: (
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
" shown2 = {\"a\": 1, \"b\": 2}",
),
indent: Indent::Spaces(4),
rename: (
at("src/tasks/models.py", "class TaskList:", "TaskList"),
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
label: "datetime",
result: "import datetime",
},
organize_imports: Some((
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
Imports::Before("import os"),
)),
// basedpyright has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs basedpyright and ruff"]
async fn python() {
smoke("python", PYTHON).await;
}
const RUST: Checks = Checks {
fixture: "rust-app",
files: &[],
warm_up: Duration::from_secs(180),
definition: (
at("src/main.rs", "match parser::parse(SAMPLE)", "parse"),
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
),
references: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), &["src/main.rs", "src/parser.rs"]),
hover: (at("src/main.rs", "inventory.take(&Sku", "take"), "pub fn take("),
completion: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory."), "low_stock"),
quick_fix: QuickFix {
// rust-analyzer's own analysis, pulled as the file changes (no save, no `cargo check`).
typed: typed(
"src/main.rs",
"let mut inventory = Inventory::new();",
" let _blank = parser::ParseError { line: 1 };",
),
diagnostic: "missing structure fields",
title: "Fill struct fields",
result: "ParseError { line: 1, message",
},
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
format: (
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
" let _spaced = (1, 2);",
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
label: "HashSet",
result: "use std::collections::HashSet;",
},
// rust-analyzer has no `source.organizeImports`.
organize_imports: None,
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs rust-analyzer"]
async fn rust() {
smoke("rust", RUST).await;
}
const GO: Checks = Checks {
fixture: "go-app",
files: &[],
warm_up: Duration::from_secs(180),
definition: (
at("cmd/server/main.go", "s := store.New()", "New"),
at("internal/store/store.go", "func New() *Store", "New"),
),
references: (
at("internal/store/store.go", "func New() *Store", "New"),
&["cmd/server/main.go", "internal/store/store.go"],
),
hover: (
at("cmd/server/main.go", "writeJSON(w, s.Search(", "Search"),
"func (s *store.Store) Search(q string) []store.Note",
),
completion: (typed("cmd/server/main.go", "s := store.New()", "\ts."), "Create"),
quick_fix: QuickFix {
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
diagnostic: "undefined: fmt",
title: "\"fmt\"",
result: "\t\"fmt\"",
},
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
indent: Indent::Tabs,
rename: (
at("internal/store/store.go", "func New() *Store", "New"),
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
label: "ToUpper",
result: "\t\"strings\"",
},
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
// gopls renames packages, not files: moving a Go file changes no references.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs gopls"]
async fn go() {
smoke("go", GO).await;
}
const JAVA: Checks = Checks {
fixture: "java-app",
files: &[],
warm_up: Duration::from_secs(300),
definition: (
at("src/main/java/com/example/orders/App.java", "book.place(\"alice\", \"Mechanical", "place"),
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
),
references: (
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
&["src/main/java/com/example/orders/App.java", "src/test/java/com/example/orders/OrderBookTest.java"],
),
hover: (
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents()", "revenueCents"),
"long com.example.orders.OrderBook.revenueCents()",
),
completion: (
typed("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", " book."),
"largest",
),
quick_fix: QuickFix {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" List<Order> all = book.byCustomer(\"alice\");",
),
diagnostic: "List cannot be resolved to a type",
title: "Import 'List' (java.util)",
result: "import java.util.List;",
},
intention: (
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
"Extract to local variable",
),
format: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" long doubled=revenue*2;",
),
" long doubled = revenue * 2;",
),
indent: Indent::Spaces(4),
rename: (
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" book.place(",
),
"customer",
),
outline: (
"src/main/java/com/example/orders/OrderBook.java",
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
),
auto_import: AutoImport {
typed: typed(
"src/main/java/com/example/orders/App.java",
"long revenue = book.revenueCents();",
" ArrayLis",
),
label: "ArrayList",
result: "import java.util.ArrayList;",
},
organize_imports: Some((
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
Imports::Removed,
)),
file_move: Some((
"src/main/java/com/example/orders/OrderBook.java",
"src/main/java/com/example/orders/Ledger.java",
"src/main/java/com/example/orders/App.java",
"Ledger book",
)),
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs jdtls and JDK 21"]
async fn java() {
smoke("java", JAVA).await;
}
const KOTLIN: Checks = Checks {
fixture: "kotlin-gradle",
files: &[],
warm_up: Duration::from_secs(420),
definition: (
at("src/main/kotlin/com/example/library/App.kt", "val library = Library()", "Library"),
at("src/main/kotlin/com/example/library/Library.kt", "class Library(", "Library"),
),
references: (
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
hover: (
at("src/main/kotlin/com/example/library/App.kt", "library.search(\"java\")", "search"),
"fun search(query: String): List<Book>",
),
completion: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library."),
"lend",
),
quick_fix: QuickFix {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTime.now()",
),
diagnostic: "Unresolved reference",
title: "Import",
result: "import java.time.LocalDateTime",
},
intention: (
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
"block body",
),
format: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
" val pair = listOf(1, 2)",
),
indent: Indent::Spaces(4),
rename: (
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
),
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
auto_import: AutoImport {
typed: typed(
"src/main/kotlin/com/example/library/App.kt",
"val library = Library()",
" val stamp = LocalDateTi",
),
label: "LocalDateTime",
result: "import java.time.LocalDateTime",
},
organize_imports: Some((
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
Imports::Removed,
)),
// kotlin-lsp has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs kotlin-lsp, a JDK and Gradle"]
async fn kotlin() {
smoke("kotlin", KOTLIN).await;
}
-46
View File
@@ -1,46 +0,0 @@
//! 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, &current), "every open returns the window's one project");
}
host.close_project("main");
}
+22 -228
View File
@@ -6,7 +6,6 @@
//! `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;
@@ -215,7 +214,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 = dunce::canonicalize(project).unwrap();
let project = project.canonicalize().unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
@@ -360,7 +359,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(), to_ipc(&f.project));
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
@@ -494,15 +493,12 @@ fn running_calls_can_be_cancelled() {
let f = f.clone();
std::thread::spawn(move || f.run("misbehave", "spin", None))
};
// A cancel before the call runs (the instance may still be starting) does nothing: repeat it.
let until = Instant::now() + Duration::from_secs(5);
while !spinning.is_finished() {
assert!(Instant::now() < until, "the running call wasn't interrupted");
f.host.cancel("misbehave");
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(100));
let started = Instant::now();
f.host.cancel("misbehave");
let err = spinning.join().unwrap().unwrap_err();
assert!(err.contains("was cancelled"), "{err}");
assert!(started.elapsed() < Duration::from_secs(1));
assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"), "no backoff");
let stats = f.host.info_of("misbehave").unwrap().stats;
assert_eq!((stats.crashes, stats.activations), (0, 2));
@@ -544,13 +540,7 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
let provider = host.syntax_provider();
let mis = LanguageId::new("mis");
// The first call starts the instance, which may take longer than the soft deadline.
let mut tokens = None;
assert!(eventually(|| {
tokens = provider.highlights(&mis, "fast");
tokens.is_some()
}));
let tokens = tokens.unwrap();
let tokens = provider.highlights(&mis, "fast").unwrap();
assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword"));
// Slower than the soft deadline: dropped (however late this thread gets
@@ -610,9 +600,8 @@ fn providers_of_several_plugins_run_concurrently() {
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// Activation is not what is measured: a first call that starts an instance
// can run past the edit-path budget, and its answer is dropped.
assert!(eventually(|| host.provide_completion(&doc("fast"), at, None).len() == 2));
// Activation is not what is measured.
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
let started = Instant::now();
let items = host.provide_completion(&doc("sleep:500"), at, None);
@@ -640,7 +629,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
assert_eq!(f.host.instances(), 0);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
assert_eq!(f.command_titles("misbehave").len(), 8, "contributions stay");
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
@@ -1678,21 +1667,22 @@ fn official_ai_completion_cancels_superseded_requests() {
};
let at = ide_core::Pos::new(1, 4);
let host = Arc::new(f);
// A slow request, superseded by the next keystroke's once the server has it
// (starting the instance comes first, and takes longer on a slow runner).
// A slow request, superseded 200 ms later by the next keystroke's.
let first = ide_core::CancellationToken::new();
let slow = {
let (host, doc, first) = (host.clone(), doc.clone(), first.clone());
std::thread::spawn(move || (host.host.provide_inline_completion(&doc, at, &first), Instant::now()))
std::thread::spawn(move || {
let started = Instant::now();
let r = host.host.provide_inline_completion(&doc, at, &first);
(r, started.elapsed())
})
};
assert!(eventually(|| seen.lock().len() == 1), "the first request reaches the server");
let cancelled = Instant::now();
std::thread::sleep(Duration::from_millis(200));
first.cancel();
let started = Instant::now();
let second = host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new());
let took = started.elapsed();
let (old, old_done) = slow.join().unwrap();
let old_took = old_done.saturating_duration_since(cancelled);
let (old, old_took) = slow.join().unwrap();
assert!(old.is_none(), "the superseded request yields nothing");
assert!(old_took < Duration::from_secs(1), "cancelled, not waited out: {old_took:?}");
assert_eq!(second.map(|(_, s)| s.insert_text).as_deref(), Some("a + b"));
@@ -1741,7 +1731,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(to_ipc(&java))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
};
saved(&f);
@@ -1794,7 +1784,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(to_ipc(&a))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
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");
@@ -1819,7 +1809,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(to_ipc(&a))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
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");
@@ -1870,12 +1860,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
assert_eq!((target.path, target.range.start), (xml_path.clone(), ide_core::Pos::new(1, 14)));
let xdoc = doc(&xml_path, "xml", xml);
// Reads the interface from disk: on a slow machine that too can run past the budget.
let mut to_java = Vec::new();
assert!(eventually(|| {
to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16));
!to_java.is_empty()
}));
let to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16));
assert_eq!(
to_java.iter().map(|l| (l.path.clone(), l.range.start)).collect::<Vec<_>>(),
[(java_path.clone(), ide_core::Pos::new(3, 9))]
@@ -2046,139 +2031,6 @@ fn pinned_programs_fill_views_and_open_terminals() {
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
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());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
@@ -2552,61 +2404,3 @@ fn uninstalling_deletes_the_plugins_secrets() {
f.host.uninstall("openidea.ai-completion").unwrap();
assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::<Vec<_>>());
}
/// Plugin API 1.0 leaves room to grow: event kinds are names, and the editor
/// providers carry snippets, imports, lazily computed quick fixes and tagged
/// problems.
#[test]
fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let f = fixture(Limits::default());
let pkg = f._tmp.path().join("misbehave-providers");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = |text: &str| ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// The first call starts the instance, which may take longer than the edit-path budget.
let mut items = Vec::new();
assert!(eventually(|| {
items = f.host.provide_completion(&doc("rich"), at, None);
!items.is_empty()
}));
let item = &items[0].1;
assert_eq!(
(item.insert_text.as_str(), item.snippet, item.filter_text.as_deref()),
("f(${1:x})$0", true, Some("fun"))
);
assert_eq!(item.additional_edits[0].new_text, "use f;\n");
// Offered without edits; computed when picked.
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
let (plugin, action) = actions.into_iter().next().expect("offered");
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
// A kind this IDE doesn't know is refused, not a trap.
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
assert!(err.message.contains("no event kind"), "{}", err.message);
let err = f.host.run_command("misbehave", "subscribe", Some("file-saved"), f.inv()).unwrap_err();
assert!(err.message.contains("events"), "known, but not granted: {}", err.message);
// Tags the IDE knows, the tool that found it.
let path = f.project.join("a.txt");
f.host.run_command("misbehave", "diagnose", Some(&path.to_string_lossy()), f.inv()).unwrap();
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
-289
View File
@@ -1,289 +0,0 @@
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
//! the source and test roots detected from its build file, the index (files and symbols),
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
//! come and go, and the Maven / Gradle model.
//!
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
//! java, kotlin and python jobs):
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
use ide_host::{Host, HostConfig, ops};
use ide_index::SymbolFilter;
use ide_workspace::roots::RootKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const WINDOW: &str = "main";
struct Fixture {
dir: &'static str,
/// Detected roots: (project-relative path, kind).
roots: &'static [(&'static str, RootKind)],
/// Search Everywhere › Files: (query, file found).
file: (&'static str, &'static str),
/// Go to Class: (name, file declaring it).
class: (&'static str, &'static str),
/// Go to Symbol: (name, file declaring it).
symbol: (&'static str, &'static str),
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
added: (&'static str, &'static str),
}
struct Opened {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
async fn open(fixture: &str) -> Opened {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
let app = tmp.path().join("app");
let host = Arc::new(Host::new(HostConfig {
config_dir: app.join("config"),
data_dir: app.join("data"),
local_data_dir: app.join("data"),
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,
}));
host.trust.decide(&root, true, false).unwrap();
ops::project::open(&host, WINDOW, &root).await.unwrap();
Opened { host, root, _tmp: tmp }
}
/// Polls `check` until it holds; fails with its last answer.
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
let deadline = Instant::now() + timeout;
loop {
match check() {
Ok(()) => return,
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
impl Opened {
fn files(&self, query: &str) -> Vec<String> {
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
found.into_iter().map(|f| f.rel_path).collect()
}
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
}
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
let found = self.symbols(name, filter);
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
}
}
async fn smoke(f: Fixture) {
let p = open(f.dir).await;
until("indexing", Duration::from_secs(30), || {
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
})
.await;
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
for (path, kind) in f.roots {
assert!(
roots.iter().any(|r| r.path == *path && r.kind == *kind),
"{}: no {kind:?} root {path:?} in {roots:?}",
f.dir
);
}
let files = p.files(f.file.0);
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
}
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
}
// The index follows the file system while the project is open.
let (added, text) = f.added;
std::fs::write(p.root.join(added), text).unwrap();
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
.await;
std::fs::remove_file(p.root.join(added)).unwrap();
until("a deleted file's class", Duration::from_secs(20), || {
let found = p.symbols("FreshWidget", SymbolFilter::Types);
if found.is_empty() { Ok(()) } else { Err(found) }
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rust_app() {
smoke(Fixture {
dir: "rust-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("pars", "src/parser.rs"),
class: ("Inventory", "src/lib.rs"),
symbol: ("low_stock", "src/lib.rs"),
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn ts_app() {
smoke(Fixture {
dir: "ts-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("cart", "src/cart.ts"),
class: ("Cart", "src/cart.ts"),
symbol: ("formatMoney", "src/money.ts"),
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn python_app() {
smoke(Fixture {
dir: "python-app",
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
file: ("models", "src/tasks/models.py"),
class: ("TaskList", "src/tasks/models.py"),
symbol: ("build_demo", "src/tasks/cli.py"),
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn go_app() {
smoke(Fixture {
dir: "go-app",
roots: &[("", RootKind::Source)],
file: ("store", "internal/store/store.go"),
class: ("Store", "internal/store/store.go"),
symbol: ("writeJSON", "cmd/server/main.go"),
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
})
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn java_app() {
smoke(Fixture {
dir: "java-app",
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
added: (
"src/main/java/com/example/orders/FreshWidget.java",
"package com.example.orders;\n\npublic class FreshWidget {}\n",
),
})
.await;
let p = open("java-app").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
let maven = model.maven.expect("a Maven model");
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn kotlin_gradle() {
smoke(Fixture {
dir: "kotlin-gradle",
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
})
.await;
let p = open("kotlin-gradle").await;
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
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"]);
}
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
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![], 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);
model
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Maven and a JDK"]
async fn maven_sync() {
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
assert_eq!(maven.sync_error, None);
assert!(maven.synced_at.is_some());
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
// The resolved tree, not only the declaration.
assert!(!junit.children.is_empty(), "{junit:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs Gradle and a JDK"]
async fn gradle_sync() {
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();
// The build's own tasks: the application and Kotlin plugins' too.
for task in ["run", "compileKotlin", "test"] {
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs python3"]
async fn python_venv() {
let p = open("python-app").await;
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
assert!(made.unwrap().success());
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "project smoke: needs JDK 21"]
async fn jdk() {
let p = open("java-app").await;
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
}
+1 -2
View File
@@ -43,7 +43,6 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_proxy_applies_and_the_password_is_saved_apart() {
ide_ssh::secrets::testing::use_memory_store();
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
assert_eq!(proxy().mode, ProxyMode::Auto);
@@ -62,7 +61,7 @@ async fn manual_proxy_applies_and_the_password_is_saved_apart() {
assert!(!settings_files(tmp.path()).contains("s3cret"), "no password in settings.toml");
let saved = std::fs::read_to_string(tmp.path().join("data/proxy-password.json")).unwrap();
assert!(!saved.contains("s3cret"), "the password is in the credential store, not on disk: {saved}");
assert!(saved.contains("s3cret"));
assert_eq!(ops::project::settings(&host, "main").proxy.password, "s3cret");
// A whole-table write keeps the password out of the file too; an empty one forgets it.
+4 -6
View File
@@ -228,9 +228,7 @@ async fn serves_projects_files_documents_search_and_runs() {
// Real project-host ignore writes travel through the remote command dispatcher.
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
let ignore_root = dunce::canonicalize(&project).unwrap();
// Not `.tmp`: a machine's global excludes file (often `*.tmp`) already
// ignores those, so the backend never writes the entry anywhere.
for (name, destination) in [("shared.ignore-me", "gitignore"), ("private.ignore-me", "localExclude")] {
for (name, destination) in [("shared.tmp", "gitignore"), ("private.tmp", "localExclude")] {
let path = ignore_root.join(name);
std::fs::write(&path, "temporary").unwrap();
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [path], "destination": destination })).await.unwrap();
@@ -239,9 +237,9 @@ async fn serves_projects_files_documents_search_and_runs() {
assert_eq!(entry["ignored"], true);
assert_eq!(entry["excluded"], false);
}
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.ignore-me"));
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.ignore-me"));
let outside = tmp.path().join("outside.ignore-me");
assert!(std::fs::read_to_string(project.join(".gitignore")).unwrap().contains("/shared.tmp"));
assert!(std::fs::read_to_string(project.join(".git/info/exclude")).unwrap().contains("/private.tmp"));
let outside = tmp.path().join("outside.tmp");
std::fs::write(&outside, "keep").unwrap();
assert!(
invoke(&peer, "vcs_add_to_ignore", json!({ "paths": [outside], "destination": "gitignore" })).await.is_err()
-16
View File
@@ -141,19 +141,3 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
let tmp = tempfile::tempdir().unwrap();
let host = host(tmp.path());
let text = "allprojects { }\n".to_string();
let path =
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
assert!(path.contains("openidea-gradle-tests-"), "{path}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
assert_eq!(again, path);
// A name is not a path.
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
assert!(escaped.is_err(), "{escaped:?}");
}
-1
View File
@@ -249,7 +249,6 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
ide_core::RequestId(472),
false,
vec![],
None,
ops::Channel::new(|_| {}),
)
};
-100
View File
@@ -1,100 +0,0 @@
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops};
use std::{sync::Arc, time::Duration};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("Game");
for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] {
std::fs::create_dir_all(root.join(name)).unwrap();
}
std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap();
std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap();
std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap();
std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap();
std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap();
std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap();
let host = 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,
});
let project = host.open_project("main", &root).unwrap();
let info = ops::project::info(&project);
assert_eq!(info.name, "Game");
let unity = info.unity.unwrap();
assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1"));
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);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
let hits = Arc::new(std::sync::Mutex::new(Vec::new()));
let out = hits.clone();
let query = serde_json::from_value(serde_json::json!({
"pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false,
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100,
}))
.unwrap();
ops::search::start(
&host,
"main",
RequestId::next(),
query,
Arc::new(move |event| {
if let ops::search::SearchEvent::File { file } = event {
out.lock().unwrap().push(file.rel_path);
}
}),
)
.await
.unwrap();
assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]);
let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap();
assert!(listing.iter().any(|e| e.name == "Library" && e.excluded));
assert!(project.exclude_globs().contains(&"/Obj".to_owned()));
// Project metadata refreshes without reopening when Unity generates its solution.
std::fs::write(root.join("Game.sln"), "").unwrap();
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
let unity = ops::project::info(&project).unity.unwrap();
assert_eq!(unity.solutions, ["Game.sln"]);
assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]);
// Desktop commands and remote RPC share these host operations.
let original = root.join("Assets/Scripts/Player.cs");
let renamed = root.join("Assets/Scripts/Hero.cs");
let copied = root.join("Assets/Scripts/HeroCopy.cs");
let meta = |path: &std::path::Path| -> std::path::PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(".meta");
name.into()
};
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
std::fs::write(meta(&original), original_metadata).unwrap();
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
assert!(!original.exists() && !meta(&original).exists());
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
assert!(copied.exists());
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
host.close_project("main");
let ordinary = base.join("ConsoleApp");
std::fs::create_dir(&ordinary).unwrap();
std::fs::write(ordinary.join("App.csproj"), "").unwrap();
let project = host.open_project("main", &ordinary).unwrap();
assert!(ops::project::info(&project).unity.is_none());
assert!(!project.exclude_globs().contains(&"/Library".to_owned()));
host.close_project("main");
}
-209
View File
@@ -1,209 +0,0 @@
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
//! a merge that conflicts, resolved and committed. Open documents follow every step.
use ide_core::DocId;
use ide_host::{Host, HostConfig, ops};
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
const WINDOW: &str = "main";
/// The line every branch changes, in `src/lib.rs`.
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
fn git(root: &Path, args: &[&str]) -> String {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_dir(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
}
}
}
struct Flow {
host: Arc<Host>,
root: PathBuf,
_tmp: tempfile::TempDir,
}
impl Flow {
async fn new(fixture: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let root = base.join(fixture);
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
git(&root, &["init", "-q", "-b", "main"]);
for (key, value) in [
("user.name", "Test"),
("user.email", "[email protected]"),
("commit.gpgsign", "false"),
("core.autocrlf", "false"),
] {
git(&root, &["config", key, value]);
}
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "-m", "Initial commit"]);
let 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(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Flow { host, root, _tmp: tmp }
}
async fn open(&self, file: &str) -> DocId {
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
}
async fn text(&self, doc: DocId) -> String {
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
}
/// Types `with` over `LINE` in the editor (unsaved).
async fn edit_line(&self, doc: DocId, with: &str) {
let text = self.text(doc).await;
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
let project = self.host.project(WINDOW).unwrap();
project.documents.replace_all(doc, &text.replacen(&current, with, 1)).unwrap();
}
async fn save(&self, doc: DocId) {
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
}
async fn status(&self) -> Vec<FileStatus> {
ops::vcs::status(&self.host, WINDOW).await.unwrap()
}
async fn commit(&self, files: &[&str], message: &str) -> String {
let paths = files.iter().map(|f| self.root.join(f)).collect();
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
let options = CommitOptions { run_hooks: true, ..Default::default() };
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
}
async fn head(&self) -> ide_vcs::Commit {
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
log.into_iter().next().expect("a commit")
}
}
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
status.iter().find(|s| s.rel_path == file)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn change_commit_branch_stash_and_merge_a_conflict() {
let f = Flow::new("rust-app").await;
let lib_path = f.root.join("src/lib.rs");
let lib = f.open("src/lib.rs").await;
let original = f.text(lib).await;
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
let base = "pub fn format_price(cents: u64) -> String { // base";
f.edit_line(lib, base).await;
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
assert!(
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
"{hunks:?}"
);
assert!(f.status().await.is_empty(), "nothing saved yet");
// Saved: Local Changes lists it, and its diff shows the edit.
f.save(lib).await;
let status = f.status().await;
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
// Commit: the log's head; nothing left to commit; no gutter marks.
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
let head = f.head().await;
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
assert!(f.status().await.is_empty());
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
// A branch changes the line its way.
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
f.edit_line(lib, theirs).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Feature's change").await;
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
// Back on main, the open file shows main's text.
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
assert!(f.text(lib).await.contains(base));
// Stash: work in progress put aside and brought back.
let main_rs = f.open("src/main.rs").await;
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
f.save(main_rs).await;
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
assert!(f.status().await.is_empty());
assert!(!f.text(main_rs).await.contains("work in progress"));
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
assert_eq!(f.text(main_rs).await, wip);
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
assert!(f.status().await.is_empty());
// Main changes the same line: merging the branch conflicts.
let ours = "pub fn format_price(cents: u64) -> String { // main";
f.edit_line(lib, ours).await;
f.save(lib).await;
f.commit(&["src/lib.rs"], "Main's change").await;
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
let status = f.status().await;
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
assert!(
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
"merge: {merged:?}; status: {status:?}"
);
// The open file shows the conflict as Git left it.
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
let resolved = versions.theirs.clone().unwrap();
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
f.save(lib).await;
f.commit(&["src/lib.rs"], "Merge feature").await;
let head = f.head().await;
assert_eq!(head.parents.len(), 2, "{head:?}");
assert!(f.status().await.is_empty());
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
}
-63
View File
@@ -175,66 +175,3 @@ async fn contended_version_reads_do_not_block_the_async_worker() {
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
use ide_lsp::types::{CodeAction, Command};
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("imports.txt");
std::fs::write(&file, "unused\nused\n").unwrap();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("local-data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
host.open_light("main", &file).unwrap();
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
let action = |edit: bool| CodeAction {
title: "Organize Imports".into(),
kind: Some("source.organizeImports".into()),
is_preferred: false,
disabled_reason: None,
edit: edit.then(|| {
serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [
{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" }
] }] }))
.unwrap()
}),
command: Some(Command {
title: String::new(),
command: "_typescript.didOrganizeImports".into(),
arguments: vec![],
}),
data: None,
server: ide_core::ServerId(1),
};
let follow_up = action(true).command.unwrap();
let asked = ops::lsp::lsp_resolve_command(
&host,
"main",
ide_core::RequestId(1),
info.id,
ide_core::ServerId(1),
follow_up,
vec![],
)
.await;
assert!(matches!(asked, Ok(None)), "{asked:?}");
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![])
.await
.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n");
ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap();
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "");
// Without an edit the command is the action: it still can't run.
let ran =
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await;
assert!(ran.is_err(), "{ran:?}");
}
-51
View File
@@ -114,54 +114,3 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn php_language_level_follows_the_member_of_the_file() {
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let (api, legacy) = (base.join("api"), base.join("legacy"));
for (member, composer) in [
(&api, r#"{"require": {"php": "^7.4"}}"#),
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
] {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("composer.json"), composer).unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
let config_dir = base.join("config");
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
.unwrap();
let host = Host::new(HostConfig {
config_dir,
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,
});
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
let level_of_api = level(api.join("src/Invoice.php")).await;
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
assert_eq!(level_of_api.auto, level_of_api.effective);
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
// The member's own settings, not the workspace's.
let level_of_legacy = level(legacy.join("index.php")).await;
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
// Outside every member: the workspace's own.
let level_elsewhere = level(base.join("elsewhere.php")).await;
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
-3
View File
@@ -14,9 +14,6 @@ fn bench(c: &mut Criterion) {
for q in ["ordcon", "src/user", "PaymentService", "zzzz", "a"] {
g.bench_function(format!("find_files/{q}"), |b| b.iter(|| black_box(index.find_files(q, 50).len())));
}
for q in ["util", "src/ui", "a"] {
g.bench_function(format!("find_dirs/{q}"), |b| b.iter(|| black_box(index.find_dirs(q, 50).len())));
}
for q in ["OCo", "invoiceRepo", "renderView", "q"] {
g.bench_function(format!("find_symbols/{q}"), |b| {
b.iter(|| black_box(index.find_symbols(q, SymbolFilter::All, 50).len()))
Loaded 100 of 837 files, more files were not shown because too many files have changed in this diff. Show more