Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai cccefcc078 feat(plugins): open the plugins page on Available 2026-11-11 20:06:13 +08:00
l0ng-ai 4bb76acf66 feat(plugins): drop the Preview badge from the plugins page 2026-11-11 20:04:43 +08:00
l0ng-ai 187c0324fd fix(plugins): plugin icons fill their tile like the initials 2026-11-11 20:03:46 +08:00
l0ng-ai 2fca3e0607 feat(plugins): a marketplace that helps pick plugins
Rows show each plugin's description, publisher, downloads and an inline
Install / Update button; plugins without an icon get a tinted initial.
While browsing, the list opens with suggestions for the open project and
the maintainers' picks, then all community plugins by downloads; the
official plugins come last as examples. The first plugin is selected so
the details are never empty, and they show release notes, the homepage
and why a plugin is suggested.

The index gains optional homepage, notes, downloads, featured and suggest
fields; openidea-plugin index fills homepage and notes from the manifest.
2026-11-11 19:43:34 +08:00
917 змінених файлів з 2889 додано та 37006 видалено

No files matched your search

-83
Переглянути файл
@@ -1,83 +0,0 @@
---
name: eval
description: Run the code-intelligence evals (`pnpm eval`), pick the worst failing scenario, diagnose it from the attached timeline, fix it, prove the fix with before / after numbers measured on this machine without regressing other scenarios, and open one PR. Use for "跑 eval", "优化 Java 代码智能", "eval 失败了", an issue labelled `eval`, or any change to Java navigation / usages / gutter markers that needs numbers.
---
# Eval loop: measure → fix → prove
`pnpm eval` drives the production workbench in Chromium against a release `openidea-server`
with real language servers (Java / jdtls for now) and judges every scenario on what the user
sees: time to feedback, to the first result, to the final one, and the results themselves vs
`evals/<language>/golden/`. One machine, before and after: numbers from different machines or
from the nightly host don't judge a fix.
## 0. Preconditions
- `jdtls` on PATH and `JAVA_HOME` pointing at a JDK of the pinned `JAVA` version (projects
compile against it, and the JDK scenarios' results are in its sources; jdtls itself runs on
a newer installed JDK when it needs one). Pinned versions: `.github/tool-versions.env`. A
mismatch prints a warning and the report records both: say so in the PR when it applies.
- First run builds `cargo build --release -p ide-host --bin openidea-server` and the perf
bundle; later iterations of a frontend-only change can use `--no-build` after a
`pnpm exec vite build --mode perf --outDir target/eval/dist`, a backend-only one after the
cargo build alone.
- Run evals one at a time on this machine, with nothing heavy (builds, other evals) beside them.
## 1. Baseline on the base commit
```sh
git switch --detach origin/main # or the fix branch's merge base
pnpm eval --save-baseline # target/eval/baseline.json, this machine only
```
Read `target/eval/report.json`. Each verdict has `status`, `failures` (`kind`: `error`,
`timeout`, `degraded`, `correctness`, `budget`, `golden`), `regressions`, `repro` (the command
that reruns it) and, for failures, `diagnostics`: every command sent to the server in the
slowest run's window (`cmd`, `start`, `ms`, `ok`, `bytes`, `items`), background tasks, server
status changes and the server's log tail.
## 2. Pick one problem
Worst first: wrong or missing results and silent degradation, then timeouts, then the
feedback budget (100 ms), then the slowest `completeMs`. One problem per PR. Say which
scenario and metric, with its value vs budget.
## 3. Diagnose from the timeline
- Commands in the window ordered by `start`: what ran, what waited on what, what's serial
that could overlap, which request returned many `items`/`bytes`, which failed.
- `feedbackMs` far above 16 ms with a fast `firstResultMs`: the UI shows nothing while it
waits (a hint or popup that only appears after a delay).
- A task still running across the window: background work competing with the request.
- For the frontend side rerun with `--trace` and open `target/eval/traces/<id>@<cond>.json`
in Chrome's Performance panel.
- Name the cause at class level (as in `issue-fix`): it usually affects sibling scenarios.
## 4. Fix, then rerun that scenario until it passes
```sh
pnpm eval --only <scenario> --condition <warm|cold> # the verdict's `repro`
```
Correctness is enforced, so a faster answer that drops, truncates or serves stale results
fails. Don't trade a budget miss for a degraded answer.
## 5. Full suite, then PR
```sh
pnpm eval # vs the baseline from step 1: no new failures or regressions
```
PR per `issue-fix` §6, plus in **How it was verified** the before / after table from
`target/eval/report.md` (both on this machine, commits named) and the tool versions line.
## Guard rails
- Goldens (`evals/*/golden/`) and budgets (`evals/budgets.json`) are human-owned. Don't edit
them, and don't run `--update-golden`, to make a scenario pass. When a golden looks wrong
(a tool bump, a deliberate behaviour change), stop and say so: a PR that touches them gets
`needs-decision`.
- Don't weaken a scenario's checks or timeouts in `evals/<language>/scenarios.ts` to pass.
Adding a scenario (for a case the sweep or a user found) is fine; record its golden with
`--update-golden --only <id>` and ask a person to review the file.
- A flaky timing (passes on rerun) is not a regression: rerun once before acting on one.
-1
Переглянути файл
@@ -1 +0,0 @@
../../.agents/skills/eval
-2
Переглянути файл
@@ -1,2 +0,0 @@
custom:
- https://github.com/l0ng-ai/openidea#sponsor
Бінарний файл не відображається.

До

Ширина:  |  Висота:  |  Розмір: 70 KiB

Бінарний файл не відображається.

До

Ширина:  |  Висота:  |  Розмір: 97 KiB

-14
Переглянути файл
@@ -1,14 +0,0 @@
# Language server and tool versions for Language smoke (.github/workflows/language-smoke.yml)
# and the code-intelligence evals (pnpm eval). Pinned so a release can't fail an unrelated
# PR or move a golden result; bump them deliberately (and re-record goldens: pnpm eval --update-golden).
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
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
JAVA=21
+11 -4
Переглянути файл
@@ -24,7 +24,6 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
- ".github/tool-versions.env"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
@@ -39,6 +38,16 @@ concurrency:
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
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -52,8 +61,6 @@ jobs:
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
# Tool versions, shared with the evals (pnpm eval).
- run: grep -E '^[A-Z0-9_]+=' .github/tool-versions.env >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with: { components: "rust-analyzer, rustfmt" }
# One build cache for every language; main saves it.
@@ -89,7 +96,7 @@ jobs:
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
with: { distribution: temurin, java-version: "21" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
-1
Переглянути файл
@@ -40,7 +40,6 @@ CLAUDE.local.md
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
!.claude/skills/eval
# Local reference screenshots
pycharm-screenshots/
-2
Переглянути файл
@@ -9,11 +9,9 @@
"dist",
"target",
"src/bindings.ts",
"evals/*/golden",
"src/editor/core/cm/angular/parser*.ts",
"src/editor/core/cm/jsp/parser*.ts",
"src/editor/core/cm/astro/parser*.ts",
"src/editor/core/cm/django/parser*.ts",
"plugins/themes.md",
"pnpm-lock.yaml",
"**/*.toml",
+3 -11
Переглянути файл
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
`fixtures/` — sample projects per language for manual and automated testing.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
@@ -59,12 +59,9 @@ Unclear product decisions → `needs-decision` and stop.
## Skills
Do not use Impeccable or its related skills in this repository, including globally installed copies.
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
`issue-fix` takes an issue from reproduction to one PR;
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
`issue-fix` takes an issue from reproduction to one PR.
## Commands
@@ -84,11 +81,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
usages, gutter markers) in the production workbench against a release `openidea-server` and
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
Створено
+49 -122
Переглянути файл
@@ -2637,6 +2637,7 @@ version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
dependencies = [
"generic-array 0.14.7",
"rustversion",
"typenum",
]
@@ -4144,7 +4145,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dunce",
"encoding_rs",
@@ -4168,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4182,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-android"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"serde",
@@ -4193,7 +4194,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"blake3",
"chardetng",
@@ -4211,7 +4212,7 @@ dependencies = [
[[package]]
name = "ide-cli"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -4225,7 +4226,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"gtk",
"objc2 0.6.5",
@@ -4235,7 +4236,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dunce",
"encoding_rs",
@@ -4252,7 +4253,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-run",
@@ -4266,7 +4267,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"futures",
"ide-core",
@@ -4292,7 +4293,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"bb8",
"blake3",
@@ -4302,7 +4303,6 @@ dependencies = [
"ide-core",
"ide-http",
"ide-ssh",
"notify",
"oracledb",
"parking_lot",
"reqwest",
@@ -4323,7 +4323,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"serde",
@@ -4336,7 +4336,7 @@ dependencies = [
[[package]]
name = "ide-extension"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4369,7 +4369,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4382,7 +4382,7 @@ dependencies = [
[[package]]
name = "ide-heap"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"criterion",
"ide-core",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -4459,7 +4459,6 @@ dependencies = [
"specta-typescript",
"tempfile",
"tokio",
"tokio-tungstenite",
"toml 0.9.12+spec-1.1.0",
"tracing",
"tracing-subscriber",
@@ -4470,7 +4469,7 @@ dependencies = [
[[package]]
name = "ide-http"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-vfs",
@@ -4489,7 +4488,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"blake3",
"criterion",
@@ -4511,7 +4510,7 @@ dependencies = [
[[package]]
name = "ide-jdwp"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-jvm-inspect",
"proptest",
@@ -4523,7 +4522,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"encoding_rs",
"futures",
@@ -4540,7 +4539,7 @@ dependencies = [
[[package]]
name = "ide-jvm-attach"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"libc",
"tempfile",
@@ -4549,7 +4548,7 @@ dependencies = [
[[package]]
name = "ide-jvm-inspect"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"futures",
"serde",
@@ -4560,7 +4559,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"blake3",
"criterion",
@@ -4616,10 +4615,9 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dunce",
"encoding_rs",
"futures",
"globset",
"ide-core",
@@ -4644,7 +4642,7 @@ dependencies = [
[[package]]
name = "ide-mcp"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"bytes",
"dunce",
@@ -4663,7 +4661,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"futures",
"ide-core",
@@ -4686,7 +4684,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"blake3",
"dunce",
@@ -4709,10 +4707,9 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-jvm-attach",
"ide-run",
"serde",
"serde_json",
@@ -4723,7 +4720,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dunce",
"globset",
@@ -4742,7 +4739,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dunce",
"encoding_rs",
@@ -4766,7 +4763,7 @@ dependencies = [
[[package]]
name = "ide-scaffold"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-tools",
@@ -4782,7 +4779,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dunce",
"ide-core",
@@ -4797,13 +4794,12 @@ dependencies = [
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"url",
"zip",
]
[[package]]
name = "ide-search"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"criterion",
"encoding_rs",
@@ -4824,7 +4820,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"parking_lot",
@@ -4837,7 +4833,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4847,22 +4843,18 @@ dependencies = [
"parking_lot",
"russh",
"russh-sftp",
"rustls",
"rustls-platform-verifier",
"serde",
"serde_json",
"specta",
"suppaftp",
"tempfile",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
]
[[package]]
name = "ide-syntax"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"ide-language",
@@ -4877,7 +4869,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"ide-core",
"libc",
@@ -4890,7 +4882,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"criterion",
"ide-core",
@@ -4903,7 +4895,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"flate2",
"hex",
@@ -4924,7 +4916,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"blake3",
"dunce",
@@ -4945,7 +4937,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"chardetng",
"dunce",
@@ -4969,11 +4961,11 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"dirs 6.0.0",
"ide-core",
"open",
"parking_lot",
"percent-encoding",
"serde",
"specta",
@@ -4985,7 +4977,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"blake3",
"dunce",
@@ -5554,29 +5546,6 @@ dependencies = [
"libc",
]
[[package]]
name = "lazy-regex"
version = "3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 3.0.9",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -6586,7 +6555,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.43"
version = "0.1.41"
dependencies = [
"base64 0.22.1",
"blake3",
@@ -6943,26 +6912,6 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -7711,9 +7660,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.65.1"
version = "0.64.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94be3d86c71b9ed220a675c405e35c7a02565a009bf8da2e9cc13a700320a5c5"
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
dependencies = [
"aes",
"bitflags 2.13.2",
@@ -9011,23 +8960,6 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "suppaftp"
version = "12.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b58fcf921745cca6f0127c9f822069947da8c6dbeff52e9004750bcc448171f"
dependencies = [
"async-trait",
"chrono",
"lazy-regex",
"log",
"pin-project",
"rustls-pki-types",
"thiserror 2.0.21",
"tokio",
"tokio-rustls",
]
[[package]]
name = "swift-rs"
version = "1.0.9"
@@ -9636,7 +9568,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",
@@ -9868,10 +9800,7 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
]
@@ -10565,8 +10494,6 @@ dependencies = [
"httparse",
"log",
"rand 0.9.5",
"rustls",
"rustls-pki-types",
"sha1 0.10.7",
"thiserror 2.0.21",
"utf-8",
+1 -2
Переглянути файл
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
exclude = ["sdk", "plugins"]
[workspace.package]
version = "0.1.43"
version = "0.1.41"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
@@ -68,7 +68,6 @@ parking_lot = "0.12"
rayon = "1"
dashmap = "6"
futures = "0.3"
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "__rustls-tls"] }
# errors & diagnostics
thiserror = "2"
-13
Переглянути файл
@@ -19,8 +19,6 @@
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
&nbsp;·&nbsp;
<a href="#sponsor">Sponsor</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -70,17 +68,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
- Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://openidea.sh/en/contribute/setup).
## Sponsor
If OpenIDEA saves you time, you can support its development with Alipay or WeChat Pay.
<table>
<tr>
<td align="center"><img src=".github/assets/sponsor-alipay.png" alt="Alipay QR code" width="200"><br>Alipay</td>
<td align="center"><img src=".github/assets/sponsor-wechat.png" alt="WeChat Pay QR code" width="200"><br>WeChat Pay</td>
</tr>
</table>
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
-102
Переглянути файл
@@ -1,102 +0,0 @@
//! A short-lived ACP session to learn models, effort levels and other config options
//! before the user's first task (Cursor and other ACP agents do not expose these in
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
use crate::{Shared, client_info};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
let root = root.clone();
move || launch.resolve_for_launch(&root)
})
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
let cwd_s = cwd.to_string_lossy().into_owned();
// No project MCP during discovery: Cursor can spend a long time spawning servers.
let new: Value = conn
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| e.to_string())?;
let mut options = config::options_of(&new);
config::add_client_options(&launch.id, &mut options);
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
let effort = levels_for_model_from_slice(&options);
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
(Some(model), Some(effort)) if !model.values.is_empty() => {
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
}
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
}
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
o.category.as_deref() == Some("thought_level")
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
}
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
let levels: Vec<_> = options
.iter()
.filter(|o| is_thought_level(o))
.cloned()
.map(|mut o| {
if o.category.as_deref() != Some("thought_level") {
o.category = Some("thought_level".into());
}
o
})
.collect();
(!levels.is_empty()).then_some(levels)
}
-4
Переглянути файл
@@ -14,7 +14,6 @@
//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and
//! permission requests get the v1 `toolCall`.
pub(crate) mod discover;
mod normalize;
use ide_jsonrpc::{Handler, Peer, RpcError, codes};
@@ -58,9 +57,6 @@ fn client_capabilities() -> Value {
"terminal": false,
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
"subagents": {},
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
// only when the client advertises this flag (undocumented Cursor ACP extension).
"_meta": { "parameterizedModelPicker": true },
})
}
+2 -74
Переглянути файл
@@ -93,81 +93,9 @@ fn the_negotiating_request_carries_both_versions() {
assert!(p["capabilities"].is_object());
assert_eq!(p["clientCapabilities"]["subagents"], json!({}));
assert_eq!(p["clientCapabilities"]["terminal"], false);
assert_eq!(
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
);
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
let mut expected = sent;
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
assert_eq!(expected, p["clientCapabilities"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent() {
#[derive(Default)]
struct DiscoveryAgent {
peer: std::sync::OnceLock<Peer>,
calls: parking_lot::Mutex<Vec<(String, Value)>>,
}
impl Handler for DiscoveryAgent {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push((method.to_owned(), params));
match method {
"initialize" => Ok(json!({
"protocolVersion": 1,
"agentCapabilities": { "loadSession": true }
})),
"session/new" => Ok(json!({
"sessionId": "discover",
"configOptions": [
{"id": "model", "category": "model", "type": "select", "currentValue": "m1",
"options": [{"value": "m1", "name": "One"}, {"value": "m2", "name": "Two"}]},
{"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
})),
_ => Ok(json!({})),
}
}
}
let agent = Arc::new(DiscoveryAgent::default());
let tmp = tempfile::tempdir().unwrap();
let agent_for_connector = agent.clone();
let connector: crate::Connector = Arc::new(move |_, _, client, _| {
let (ca, cb) = tokio::io::duplex(1 << 20);
let (car, caw) = tokio::io::split(ca);
let (cbr, cbw) = tokio::io::split(cb);
let agent_peer = Peer::connect_framed(cbr, cbw, agent_for_connector.clone(), Framing::Lines);
let _ = agent_for_connector.peer.set(agent_peer);
Ok(Peer::connect_framed(car, caw, client, Framing::Lines))
});
let mgr = crate::AgentManager::with_connector(
crate::AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["initialize", "session/new"]);
assert_eq!(calls[1].1["mcpServers"], json!([]));
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
assert_eq!(fast.category.as_deref(), Some("model_config"));
assert_eq!(fast.current.as_deref(), Some("false"));
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
let sent = &frames(TURN, "out")[0]["params"];
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
}
#[test]
+1 -24
Переглянути файл
@@ -107,15 +107,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
category: str_of(o, "category").or_else(|| {
matches!(id.as_str(), "model" | "mode")
.then(|| id.clone())
.or_else(|| {
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
.then(|| "thought_level".into())
})
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
}),
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
@@ -341,21 +333,6 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
"configOptions": [
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
}));
assert_eq!(c.len(), 2);
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].category.as_deref(), Some("model_config"));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
+43 -218
Переглянути файл
@@ -16,7 +16,6 @@ mod model;
mod native;
mod provider;
pub mod registry;
pub mod security;
mod skills;
mod timeline;
mod worktree;
@@ -40,9 +39,6 @@ use std::time::Duration;
/// Receives the events of one project's tasks.
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
/// Returns the active command execution security policy for the project.
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
/// Entry changes are batched for this long before they are sent.
const FLUSH_DELAY: Duration = Duration::from_millis(40);
/// Updates that arrive this late after `session/load` returns are still replayed history.
@@ -106,7 +102,6 @@ struct Shared {
runtime: tokio::runtime::Handle,
connector: Connector,
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
@@ -182,15 +177,9 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
if let Ok(found) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
@@ -209,8 +198,6 @@ impl Shared {
shared.remember_discovered(&spec.id, &options, &commands, &levels);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
@@ -233,17 +220,12 @@ impl Shared {
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
let models: Vec<&str> = known
known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
.flat_map(|o| &o.values)
.all(|v| known.thought_levels.contains_key(&v.value))
}
fn root_key(&self) -> String {
@@ -452,7 +434,6 @@ impl AgentManager {
runtime,
connector,
sink: RwLock::new(None),
security: RwLock::new(None),
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
@@ -496,11 +477,6 @@ impl AgentManager {
tasks
}
/// Sets the callback used to read the active command execution security policy.
pub fn set_security_hook(&self, hook: SecurityHook) {
*self.shared.security.write() = Some(hook);
}
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -525,9 +501,7 @@ impl AgentManager {
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs.clone();
drop(probes);
drop(prev);
*prev = specs;
list
}
@@ -548,30 +522,6 @@ impl AgentManager {
provider::set_builtin_env(&dir, id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(&self, input: CustomAgentInput) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::save_custom(&dir, &input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(&self, id: &str) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::remove_custom(&dir, id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(&self, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::import_acp(&dir, text)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -602,16 +552,10 @@ impl AgentManager {
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
if claude {
offered = self.shared.known_commands(&provider);
}
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -944,38 +888,14 @@ impl AgentManager {
}
o.clone()
};
let previous = target.current.clone();
// Cursor's `session/set_config_option` (Fast, effort) can take seconds. Show the
// choice first so the switch and slider move immediately.
apply_config_choice(&task, option, &value);
task.persist();
task.emit_task();
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
let reply: Value = match live.peer.request(method, params, None).await {
Ok(reply) => reply,
Err(e) => {
{
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
if let Some(prev) = previous.clone() {
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: prev });
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = previous.clone();
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
task.persist();
task.emit_task();
return Err(IdeError::internal(task.describe(e)));
}
};
let reply: Value =
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
if let Some(options) = reply.get("configOptions") {
config::merge_update(&mut task.state.lock().task.config, options);
model_options = true;
@@ -986,6 +906,8 @@ impl AgentManager {
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value);
}
@@ -1312,17 +1234,6 @@ impl AgentManager {
};
// A copy of the task (a fork) may work in the same worktree.
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
self.tasks.write().remove(id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
let saved = task.state.lock().saved.clone();
if let Some(saved) = saved
&& !self.tasks.read().values().any(|t| t.state.lock().saved.as_ref() == Some(&saved))
{
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
}
// The list drops the task now; removing a large worktree's folder can take a while.
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
// On Windows the agent's processes lock the folder (their working directory,
// open files) until they have exited.
@@ -1332,25 +1243,31 @@ impl AgentManager {
let worktrees = self.shared.paths.worktrees_dir.clone();
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
}
self.tasks.write().remove(id);
*task.deleted.lock() = true;
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
let saved = task.state.lock().saved.clone();
if let Some(saved) = saved
&& !self.tasks.read().values().any(|t| t.state.lock().saved.as_ref() == Some(&saved))
{
let _ = std::fs::remove_dir_all(self.shared.paths.store_dir.join(saved));
}
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
Ok(())
}
/// Files the task changed since it started.
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
let task = self.task(id)?;
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let at = self.task(id)?.base();
blocking(move || match at.rev()? {
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
None => Ok(None),
})
.await
let (cwd, base) = self.task(id)?.cwd_and_base();
blocking(move || worktree::file_at(&cwd, &path, &base)).await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
@@ -1360,28 +1277,19 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = at.changes(&before)?;
let rev = at.rev()?;
let sources = match rev.as_deref() {
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
None => Default::default(),
};
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
// A rename goes back to its old path too; the new one is taken away first, so a
// case-only rename on a case-insensitive disk ends as the old file.
for change in std::iter::once(change).chain(sources.get(&change.rel_path)) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
}
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
}
}
Ok(restamped)
@@ -1397,41 +1305,6 @@ impl AgentManager {
Ok(())
}
/// Accept: copies the task's changed files among `rel_paths` (all with `None`) from its
/// worktree into the local checkout, uncommitted, for the user to review and commit.
/// Files the checkout changed itself are left alone and reported. Only worktree tasks
/// have anything to accept.
pub async fn accept_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<AgentAcceptOutcome> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before accepting its changes"));
}
let (isolation, cwd, base, root, before) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.base_commit.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
(*s.before).clone(),
)
};
if isolation != Isolation::Worktree {
return Err(IdeError::invalid("the task works in the local checkout: there is nothing to accept"));
}
let Some(base) = base else {
return Err(IdeError::invalid("the task has no base revision to accept from"));
};
blocking(move || {
let wanted: Vec<AgentChange> = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before)
.into_iter()
.filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path)))
.collect();
worktree::accept(&root, &cwd, &base, &wanted)
})
.await
}
/// Stops every agent (project closed).
pub fn shutdown(&self) {
for task in self.tasks.read().values() {
@@ -1482,15 +1355,15 @@ impl Task {
})
}
fn base(&self) -> worktree::TaskBase {
fn cwd_and_base(&self) -> (PathBuf, String) {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
match s.task.isolation {
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
Isolation::Worktree => {
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
}
}
// In the project's own checkout the user commits too: compare against the current HEAD,
// as the Commit tool window does, so commits made since the task started drop out.
let base = match s.task.isolation {
Isolation::Local => None,
Isolation::Worktree => s.task.base_commit.clone(),
};
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
}
fn emit_task(&self) {
@@ -2250,44 +2123,6 @@ impl Task {
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
if let Some(hook) = self.shared.security.read().clone()
&& let Some(command) = security::extract_command(params)
{
match security::evaluate_command(&command, &hook()) {
security::PolicyDecision::Blocked(pattern) => {
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("Blocked by policy ({pattern})")),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
security::PolicyDecision::Allowed => {
if let Some(allow) = options
.iter()
.find(|o| o.kind == "allow_once")
.or_else(|| options.iter().find(|o| o.kind.starts_with("allow")))
.cloned()
{
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("{} (whitelisted)", allow.name)),
});
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
}
}
security::PolicyDecision::AskUser => {}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
@@ -2601,7 +2436,7 @@ fn with_details(message: String, data: Option<&Value>) -> String {
}
}
pub(crate) fn client_info() -> Value {
fn client_info() -> Value {
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
}
@@ -2660,16 +2495,6 @@ fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
})
}
fn apply_config_choice(task: &Task, option: &str, value: &str) {
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.to_owned() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value.to_owned());
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
-46
Переглянути файл
@@ -602,49 +602,3 @@ pub struct McpServerInput {
fn default_true() -> bool {
true
}
/// Payload for creating a custom ACP agent (always the ACP driver); the id
/// becomes the `<config>/agents/<id>.toml` file name.
#[derive(Clone, Debug, Deserialize, Type)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CustomAgentInput {
pub id: String,
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
/// What importing one entry of a JetBrains `acp.json` file did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AcpImportOutcome {
/// The `agent_servers` key the entry was imported from.
pub name: String,
/// The id its agent file would get (also when skipped).
pub id: String,
pub saved: bool,
/// Why the entry was skipped; empty when it was saved.
#[serde(default)]
pub reason: String,
}
/// What accepting a worktree task's changes copied into the local checkout.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptOutcome {
/// Relative paths copied into the checkout (uncommitted), including ones already identical.
pub accepted: Vec<String>,
/// Files left alone because the checkout has its own edits to them.
pub skipped: Vec<AgentAcceptSkipped>,
}
/// A file accept left alone, with why.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptSkipped {
pub path: String,
pub reason: String,
}
+1 -198
Переглянути файл
@@ -2,7 +2,7 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -278,127 +278,6 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
Ok(())
}
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
/// and an empty command. The id is also the agent file's name, so it must be
/// a safe file stem.
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
let id = validated_custom_id(&input.id)?;
if builtin_spec(&id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
}
if input.command.trim().is_empty() {
return Err(IdeError::invalid("the agent command must not be empty"));
}
let file = custom_dir.join(format!("{id}.toml"));
if file.try_exists()? {
if CustomAgent::read(&file).is_ok_and(|a| a.registry.is_some()) {
return Err(IdeError::invalid(format!(
"{id} is installed from the ACP Registry; uninstall it there to replace it"
)));
}
return Err(IdeError::invalid(format!("there is already a custom agent '{id}'")));
}
CustomAgent {
name: Some(input.name.clone()),
driver: Driver::Acp,
command: input.command.clone(),
args: input.args.clone(),
env: input.env.clone(),
registry: None,
}
.write(&file)?;
Ok(())
}
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
let id = validated_custom_id(id)?;
let id = id.as_str();
if builtin_spec(id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent and cannot be removed")));
}
let file = custom_dir.join(format!("{id}.toml"));
let agent =
CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("there is no custom agent '{id}'")))?;
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry; uninstall it there")));
}
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
/// agents, one outcome per entry; entries that collide, lack a command or
/// have no usable name are skipped with a reason.
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
#[derive(Deserialize)]
struct AcpJson {
#[serde(default)]
agent_servers: BTreeMap<String, AcpServer>,
}
#[derive(Deserialize)]
struct AcpServer {
#[serde(default)]
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
let parsed: AcpJson =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("not an acp.json file: {e}")))?;
let mut outcomes = Vec::with_capacity(parsed.agent_servers.len());
for (name, server) in parsed.agent_servers {
let id = slugify_id(&name);
let mut outcome = AcpImportOutcome { name, id: id.clone(), saved: false, reason: String::new() };
if id.is_empty() {
outcome.reason = "the name has no usable characters for an id".to_owned();
} else if builtin_spec(&id).is_some() {
outcome.reason = format!("{id} is a built-in agent");
} else if server.command.trim().is_empty() {
outcome.reason = "the entry has no command".to_owned();
} else if custom_dir.join(format!("{id}.toml")).try_exists()? {
outcome.reason = format!("there is already a custom agent '{id}'");
} else {
CustomAgent {
name: Some(outcome.name.clone()),
driver: Driver::Acp,
command: server.command,
args: server.args,
env: server.env,
registry: None,
}
.write(&custom_dir.join(format!("{id}.toml")))?;
outcome.saved = true;
}
outcomes.push(outcome);
}
Ok(outcomes)
}
/// A custom agent id doubles as a file stem: letters, digits, `-` and `_` only.
fn validated_custom_id(id: &str) -> IdeResult<String> {
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Ok(id.to_owned());
}
Err(IdeError::invalid(format!("'{id}' is not a usable agent id (letters, digits, '-' and '_' only)")))
}
/// Turns an `acp.json` server name into a file-stem id (`"My Agent!"` → `"my-agent"`).
fn slugify_id(name: &str) -> String {
let mut id = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
id.push(c.to_ascii_lowercase());
} else if !id.ends_with('-') {
id.push('-');
}
}
id.trim_matches('-').to_owned()
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -874,80 +753,4 @@ mod tests {
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
}
fn custom_input(id: &str) -> CustomAgentInput {
CustomAgentInput {
id: id.into(),
name: "Mine".into(),
command: "/opt/mine".into(),
args: vec!["acp".into()],
env: BTreeMap::from([("K".to_owned(), "v".to_owned())]),
}
}
#[test]
fn custom_agents_save_list_and_remove() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap();
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
assert_eq!(mine.source, AgentSource::Custom);
remove_custom(dir.path(), "mine").unwrap();
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), "mine").unwrap_err();
}
#[test]
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), &custom_input("")).unwrap_err();
let mut empty_command = custom_input("mine");
empty_command.command = " ".into();
save_custom(dir.path(), &empty_command).unwrap_err();
save_custom(dir.path(), &custom_input("mine")).unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), "claude").unwrap_err();
// An id that isn't a plain file stem never reaches outside the agents directory.
let outside = dir.path().join("outside.toml");
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir(&agents).unwrap();
remove_custom(&agents, "../outside").unwrap_err();
assert!(outside.exists());
std::fs::write(
dir.path().join("amp-acp.toml"),
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
)
.unwrap();
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), "amp-acp").unwrap_err();
}
#[test]
fn acp_json_imports_servers_and_skips_collisions() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("taken")).unwrap();
let outcomes = import_acp(
dir.path(),
r#"{"agent_servers": {
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
"taken": {"command": "/opt/taken"},
"claude": {"command": "/opt/claude"},
"!!!": {"command": "/opt/noname"},
"silent": {"args": []}
}}"#,
)
.unwrap();
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
assert_eq!(saved, ["my-agent"]);
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
assert_eq!(mine.source, AgentSource::Custom);
import_acp(dir.path(), "not json").unwrap_err();
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
}
}
-487
Переглянути файл
@@ -1,487 +0,0 @@
//! Command execution security policy (Whitelist / Blacklist) for coding agents.
use ide_core::settings::AgentSecuritySettings;
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicyDecision {
/// Command matched a blacklist pattern (`pattern`).
Blocked(String),
/// Command matched the whitelist and no blacklist pattern.
Allowed,
/// Neither matched; prompt the user as usual.
AskUser,
}
/// Extracts the shell command string from an ACP `session/request_permission` payload
/// when the request is for executing a command.
pub fn extract_command(params: &Value) -> Option<String> {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
return Some(cmd);
}
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let title =
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
return Some(cmd.to_owned());
}
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
&& !title.is_empty()
&& title != "Terminal"
&& title != "Run a command"
{
return Some(title.to_owned());
}
None
}
fn command_from_value(val: &Value) -> Option<String> {
match val {
Value::String(s) => {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
Value::Array(items) => {
let words: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
if words.is_empty() {
return None;
}
let cmd = match words.as_slice() {
[_, flag, script] if flag.starts_with('-') && flag.ends_with('c') => (*script).trim().to_owned(),
_ => words.join(" ").trim().to_owned(),
};
(!cmd.is_empty()).then_some(cmd)
}
_ => None,
}
}
/// Checks whether a single pattern matches `command` using prefix + glob rules:
/// - If `pattern` contains `*` or `?`, matches using glob wildcards (where `*` matches
/// any sequence of characters). A trailing ` *` in the pattern also matches the bare
/// command without arguments (e.g. `git status *` matches `git status`).
/// - Otherwise, matches when `command` equals `pattern` or starts with `pattern` followed
/// by whitespace (prefix match at word boundary).
pub fn matches_command(pattern: &str, command: &str) -> bool {
let pat = pattern.trim();
let cmd = command.trim();
if pat.is_empty() || cmd.is_empty() {
return false;
}
if pat.contains('*') || pat.contains('?') {
if let Some(stem) = pat.strip_suffix(" *")
&& cmd == stem.trim()
{
return true;
}
wildcard_match(pat.as_bytes(), cmd.as_bytes())
} else {
cmd == pat || cmd.strip_prefix(pat).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
}
fn wildcard_match(pat: &[u8], text: &[u8]) -> bool {
let (mut p, mut t) = (0, 0);
let (mut star_p, mut star_t) = (None, 0);
while t < text.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == b'*' {
star_p = Some(p);
p += 1;
star_t = t;
} else if let Some(sp) = star_p {
p = sp + 1;
star_t += 1;
t = star_t;
} else {
return false;
}
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
/// Matches an allowed command entry against a sub-command.
/// An allow entry is a whole command or a prefix only up to a word boundary:
/// e.g. `git status` matches `git status` and `git status -s`, but NOT `git statusx`.
/// Any accidental trailing `*` in the allow entry is stripped so `git status*` behaves
/// cleanly as a prefix at word boundaries instead of a glob wildcard.
pub fn matches_allow_entry(entry: &str, command: &str) -> bool {
let mut entry = entry.trim();
if let Some(stripped) = entry.strip_suffix('*') {
entry = stripped.trim_end();
}
let cmd = command.trim();
if entry.is_empty() || cmd.is_empty() {
return false;
}
cmd == entry || cmd.strip_prefix(entry).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
/// Checks whether a word is an environment variable assignment (`IDENTIFIER=...`).
fn is_env_assignment_word(word: &str) -> bool {
let Some((name, _)) = word.split_once('=') else {
return false;
};
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Returns true if the sub-command starts with an environment variable assignment,
/// e.g. `FOO=bar cmd` or `PORT=3000`.
fn has_leading_env_assignment(cmd: &str) -> bool {
let trimmed = cmd.trim_start();
let bytes = trimmed.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
}
i += 1;
continue;
}
if b == b'\'' || b == b'"' {
quote = Some(b);
i += 1;
continue;
}
if b.is_ascii_whitespace() {
break;
}
i += 1;
}
let first_word = &trimmed[..i];
is_env_assignment_word(first_word)
}
/// Checks whether `command` contains shell constructs that must never be auto-approved:
/// - Command substitution: `$(...)` or backticks
/// - Redirection: `>`, `>>`, `<`
/// - Here-docs: `<<`
/// - Subshells: `(...)`
/// - Leading environment assignments: `VAR=val cmd`
pub fn has_unsafe_shell_syntax(command: &str) -> bool {
let cmd = command.trim();
if cmd.is_empty() {
return false;
}
if has_leading_env_assignment(cmd) {
return true;
}
let bytes = cmd.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
i += 1;
continue;
}
// Inside double quotes: $(...) and `...` are evaluated by the shell.
if q == b'"' {
if b == b'`' {
return true;
}
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
return true;
}
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'`' => return true,
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => return true,
b'<' | b'>' => return true,
b'(' | b')' => return true,
_ => i += 1,
}
}
false
}
/// Splits a shell command into sub-commands separated by `;`, `&&`, `||`, `|`, or `\n`
/// (outside single/double quotes) so compound commands cannot bypass policy checks.
fn split_subcommands(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = quote {
if b == b'\\' && q == b'"' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
quote = None;
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'\\' if i + 1 < bytes.len() => {
i += 2;
}
b';' | b'\n' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
i += 1;
start = i;
}
b'&' | b'|' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
if i + 1 < bytes.len() && bytes[i + 1] == b {
i += 2;
} else {
i += 1;
}
start = i;
}
_ => i += 1,
}
}
let tail = command[start..].trim();
if !tail.is_empty() {
out.push(tail);
}
out
}
/// Evaluates `command` against `settings`:
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
/// 2. Whitelist: allowed_commands cannot auto-approve when empty.
/// 3. Every sub-command in a chain must be safe (no command substitution, redirection,
/// subshell, or env assignments) and match at least one `allowed_commands` entry
/// exactly or up to word boundary; otherwise falls back to [`PolicyDecision::AskUser`].
pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> PolicyDecision {
let cmd = command.trim();
if cmd.is_empty() {
return PolicyDecision::AskUser;
}
let subcommands = split_subcommands(cmd);
for pat in &settings.blocked_commands {
let pat = pat.trim();
if pat.is_empty() {
continue;
}
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
return PolicyDecision::Blocked(pat.to_owned());
}
}
if settings.allowed_commands.is_empty() || subcommands.is_empty() {
return PolicyDecision::AskUser;
}
let all_allowed = subcommands.iter().all(|sub| {
let s = sub.trim();
if s.is_empty() || has_unsafe_shell_syntax(s) {
return false;
}
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
});
if all_allowed {
return PolicyDecision::Allowed;
}
PolicyDecision::AskUser
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prefix_and_glob_matching() {
assert!(matches_command("git status", "git status"));
assert!(matches_command("git status", "git status -s"));
assert!(!matches_command("git status", "git statusbar"));
assert!(matches_command("git status*", "git status"));
assert!(matches_command("git status*", "git status --short"));
assert!(matches_command("npm test *", "npm test"));
assert!(matches_command("npm test *", "npm test unit"));
assert!(matches_command("rm -rf *", "rm -rf /tmp/foo"));
assert!(matches_command("rm -rf *", "rm -rf /"));
}
#[test]
fn blacklist_takes_precedence_and_catches_chained_commands() {
let settings = AgentSecuritySettings::default();
assert_eq!(evaluate_command("rm -rf /tmp/build", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command(":(){ :|:& };:", &settings), PolicyDecision::Blocked(":(){ :|:& };:".into()));
}
#[test]
fn allow_entry_matching_word_boundary() {
assert!(matches_allow_entry("git status", "git status"));
assert!(matches_allow_entry("git status", "git status -s"));
assert!(!matches_allow_entry("git status", "git statusbar"));
assert!(!matches_allow_entry("git status", "git statusx"));
// Trailing asterisk in allow entry normalizes cleanly to word boundary prefix
assert!(matches_allow_entry("git status*", "git status"));
assert!(matches_allow_entry("git status*", "git status --short"));
assert!(!matches_allow_entry("git status*", "git statusbar"));
assert!(matches_allow_entry("ls", "ls"));
assert!(matches_allow_entry("ls", "ls -la"));
assert!(!matches_allow_entry("ls", "lsof"));
assert!(matches_allow_entry("cargo check", "cargo check"));
assert!(matches_allow_entry("cargo check", "cargo check --workspace"));
assert!(!matches_allow_entry("cargo check", "cargo checkmate"));
}
#[test]
fn unsafe_shell_syntax_detection() {
// Command substitutions: $(...) and `...`
assert!(has_unsafe_shell_syntax("echo $(whoami)"));
assert!(has_unsafe_shell_syntax("echo \"user: $(whoami)\""));
assert!(has_unsafe_shell_syntax("echo `whoami`"));
assert!(has_unsafe_shell_syntax("echo \"`whoami`\""));
// Redirections: >, >>, <
assert!(has_unsafe_shell_syntax("git status > /tmp/out"));
assert!(has_unsafe_shell_syntax("git status >> /tmp/out"));
assert!(has_unsafe_shell_syntax("cat < /tmp/in"));
assert!(has_unsafe_shell_syntax("cat << 'EOF'"));
// Subshells: (...)
assert!(has_unsafe_shell_syntax("(cd /tmp && ls)"));
assert!(has_unsafe_shell_syntax("git status && (exit 1)"));
// Environment assignments: VAR=val ...
assert!(has_unsafe_shell_syntax("FOO=bar git status"));
assert!(has_unsafe_shell_syntax("PORT=3000 npm test"));
assert!(has_unsafe_shell_syntax("_SECRET=123 cargo test"));
assert!(has_unsafe_shell_syntax("A=\"b c\" git status"));
// Safe constructs with characters inside quotes or command flags
assert!(!has_unsafe_shell_syntax("git commit -m \"fix <bug> and (issue)\""));
assert!(!has_unsafe_shell_syntax("git commit -m 'fix <bug>'"));
assert!(!has_unsafe_shell_syntax("cargo test --test=unit"));
assert!(!has_unsafe_shell_syntax("git status -s"));
}
#[test]
fn default_settings_has_no_auto_approval_and_blocks_dangerous_defaults() {
let settings = AgentSecuritySettings::default();
assert!(settings.allowed_commands.is_empty(), "allowed_commands must start empty");
// Safe commands ask user by default
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("ls", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo check", &settings), PolicyDecision::AskUser);
// Blacklist patterns are still blocked by default
assert_eq!(evaluate_command("rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("rm -rf /tmp/test", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn whitelist_allows_safe_single_and_chained_commands_only() {
let settings = AgentSecuritySettings {
allowed_commands: vec!["git status".into(), "git diff".into(), "cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
};
assert_eq!(evaluate_command("git status -s", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git diff HEAD~1", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git status && cargo test", &settings), PolicyDecision::Allowed);
// Word boundary: git statusx must not match git status
assert_eq!(evaluate_command("git statusx", &settings), PolicyDecision::AskUser);
// Unsafe syntax (redirection, command substitution, env assignment) falls back to AskUser
assert_eq!(evaluate_command("git status > out.txt", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("git status $(whoami)", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("FOO=bar git status", &settings), PolicyDecision::AskUser);
// Chaining an unapproved command must still ask the user.
assert_eq!(evaluate_command("git status && curl https://example.com", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo publish", &settings), PolicyDecision::AskUser);
// Blacklist still takes precedence over whitelist
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn extracts_command_from_v1_v2_and_codex_payloads() {
let claude = json!({
"toolCall": {
"title": "git status",
"kind": "execute",
"rawInput": { "command": "git status --short" }
}
});
assert_eq!(extract_command(&claude).as_deref(), Some("git status --short"));
let codex = json!({
"toolCall": {
"title": "Run cargo test",
"kind": "execute",
"rawInput": { "command": ["/bin/zsh", "-lc", "cargo test --workspace"] }
}
});
assert_eq!(extract_command(&codex).as_deref(), Some("cargo test --workspace"));
let write = json!({
"toolCall": {
"title": "Write foo.txt",
"kind": "edit",
"rawInput": { "file_path": "foo.txt" }
}
});
assert_eq!(extract_command(&write), None);
}
}
+2 -398
Переглянути файл
@@ -43,11 +43,6 @@ struct FakeAgent {
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Blocks `session/new` until discovery times out (see `acp::discover` test timeout).
hold_session_new: AtomicBool,
/// Sleeps this long at the start of `session/set_config_option`, after noting the time.
delay_config: Duration,
config_entered: Mutex<Option<std::time::Instant>>,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
@@ -162,9 +157,6 @@ impl Handler for FakeAgent {
)
}
"session/new" => {
if self.hold_session_new.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_secs(5));
}
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
@@ -198,10 +190,6 @@ impl Handler for FakeAgent {
}
}
"session/set_config_option" | "session/set_mode" => {
if method == "session/set_config_option" && !self.delay_config.is_zero() {
*self.config_entered.lock() = Some(std::time::Instant::now());
std::thread::sleep(self.delay_config);
}
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
@@ -492,7 +480,7 @@ for line in sys.stdin:
}
let concurrent = async {
if outcome == "session_report" {
tokio::time::timeout(Duration::from_secs(10), async {
tokio::time::timeout(Duration::from_secs(2), async {
while !root.join("initialized").exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
@@ -564,7 +552,7 @@ for line in sys.stdin:
};
assert_eq!(subtypes, expected, "{outcome}");
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
tokio::time::timeout(Duration::from_secs(2), async {
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
@@ -2643,92 +2631,6 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.reload_providers();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(agent.calls.lock().is_empty(), "reload must not spawn cursor-agent");
}
#[tokio::test(flavor = "multi_thread")]
async fn composer_commands_discover_cursor_effort_and_fast() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![
json!({"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}),
json!({"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}),
]),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert!(calls.iter().any(|m| m.as_str() == "session/new"));
assert_eq!(calls.iter().filter(|m| m.as_str() == "initialize").count(), 1);
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert_eq!(cursor.config.iter().find(|o| o.id == "fast").unwrap().current.as_deref(), Some("false"));
assert!(
cursor
.thought_levels
.values()
.next()
.is_some_and(|levels| { levels.iter().any(|o| o.category.as_deref() == Some("thought_level")) })
);
}
#[tokio::test(flavor = "multi_thread")]
async fn config_choice_shows_before_the_agent_answers() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![json!({
"id": "fast", "category": "model_config", "type": "select", "currentValue": "true",
"options": [{"value": "true", "name": "On"}, {"value": "false", "name": "Off"}]
})]),
delay_config: Duration::from_millis(300),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let shown: Arc<Mutex<Option<std::time::Instant>>> = Arc::default();
let mark = shown.clone();
mgr.subscribe(Arc::new(move |e| {
if let AgentEvent::Task { task } = e
&& task.config.iter().any(|o| o.id == "fast" && o.current.as_deref() == Some("false"))
&& mark.lock().is_none()
{
*mark.lock() = Some(std::time::Instant::now());
}
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "fast", "false".into()).await.unwrap();
let shown = (*shown.lock()).expect("the switch moved");
let entered = (*agent.config_entered.lock()).expect("the agent received the change");
assert!(shown < entered, "the composer updates before the agent answers");
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_times_out_when_session_new_hangs() {
let agent = Arc::new(FakeAgent { hold_session_new: AtomicBool::new(true), ..Default::default() });
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let started = std::time::Instant::now();
mgr.commands("cursor", None).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(10), "discovery should time out quickly in tests");
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
@@ -2868,301 +2770,3 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
mgr.delete(&task.id, false).await.unwrap();
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
struct Checkout {
_tmp: tempfile::TempDir,
dir: PathBuf,
eol: Eol,
}
/// How the checkout keeps line endings: as committed, or CRLF on disk with `core.autocrlf`
/// (Git for Windows' default), where the files' bytes never equal the committed blobs.
#[derive(Debug, Clone, Copy)]
enum Eol {
Lf,
Crlf,
}
const EOLS: [Eol; 2] = [Eol::Lf, Eol::Crlf];
impl Checkout {
fn new(eol: Eol) -> Self {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap();
let checkout = Self { _tmp: tmp, dir, eol };
checkout.git(&["init", "-q", "-b", "main"]);
let autocrlf = match eol {
Eol::Lf => "false",
Eol::Crlf => "true",
};
checkout.git(&["config", "core.autocrlf", autocrlf]);
checkout.write(".gitignore", "store/\nworktrees/\nagents/\nmodels.json\n");
checkout
}
fn git(&self, args: &[&str]) {
let ok = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.current_dir(&self.dir)
.status()
.unwrap()
.success();
assert!(ok, "{args:?}");
}
fn commit(&self, message: &str) {
self.git(&["add", "."]);
self.git(&["commit", "-q", "-m", message]);
}
fn write(&self, rel: &str, text: &str) {
let text = match self.eol {
Eol::Lf => text.to_owned(),
Eol::Crlf => text.replace('\n', "\r\n"),
};
std::fs::write(self.dir.join(rel), text).unwrap();
}
/// The file's text with LF line endings (a rollback writes the committed bytes back).
fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.dir.join(rel)).unwrap().replace("\r\n", "\n")
}
/// A local task, stopped so it can be rolled back.
async fn task(&self) -> (AgentManager, AgentTask) {
let mgr = manager(&self.dir, Arc::new(FakeAgent::default()));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("Hello"),
provider: "claude".into(),
config: Vec::new(),
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
mgr.cancel(&task.id, None).unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
(mgr, task)
}
}
async fn changed(mgr: &AgentManager, id: &str) -> Vec<String> {
mgr.changes(id).await.unwrap().into_iter().map(|c| c.rel_path).collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The user takes the last commit back into the working tree (`git reset --soft`, an
// undone commit): its changes are the user's, not the agent's.
repo.git(&["reset", "-q", "--soft", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
assert_eq!(base.map(|b| b.replace("\r\n", "\n")).as_deref(), Some("base\n"), "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}: the user's work survives the rollback");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.write("mine.txt", "base\n");
repo.commit("init");
repo.write("mine.txt", "my work\n");
repo.write("mine-new.txt", "my new file\n");
repo.commit("my work");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// `git reset HEAD~1`: the commit's new file is untracked now, its edit unstaged.
repo.git(&["reset", "-q", "HEAD~1"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("mine.txt"), "my work\n", "{eol:?}");
assert_eq!(repo.read("mine-new.txt"), "my new file\n", "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.write("other.txt", "base\n");
repo.commit("init");
repo.git(&["checkout", "-q", "-b", "feature"]);
repo.write("other.txt", "feature\n");
repo.write("only-feature.txt", "feature\n");
repo.commit("feature");
repo.git(&["checkout", "-q", "main"]);
repo.write("only-main.txt", "main\n");
repo.commit("main");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
// The agent's uncommitted edit comes along to a branch that doesn't contain the task's start.
repo.git(&["checkout", "-q", "feature"]);
assert_eq!(changed(&mgr, &task.id).await, ["a.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "base\n", "{eol:?}");
assert_eq!(repo.read("other.txt"), "feature\n", "{eol:?}");
assert_eq!(repo.read("only-feature.txt"), "feature\n", "{eol:?}");
assert!(!repo.dir.join("only-main.txt").exists(), "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("a.txt", "base\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.write("a.txt", "agent\n");
repo.write("b.txt", "agent\n");
// The user commits what the agent did to `a.txt` so far: it drops out of the task's changes.
repo.git(&["add", "a.txt"]);
repo.git(&["commit", "-q", "-m", "keep"]);
assert_eq!(changed(&mgr, &task.id).await, ["b.txt"], "{eol:?}");
repo.write("a.txt", "agent\nagain\n");
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"], "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert_eq!(repo.read("a.txt"), "agent\n", "{eol:?}: back to the commit, not to before it");
assert!(!repo.dir.join("b.txt").exists(), "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rolling_back_a_rename_brings_the_old_file_back() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("Old.java", "class Old {}\n");
repo.write("Case.java", "class Case {}\n");
repo.commit("init");
let (mgr, task) = repo.task().await;
repo.git(&["mv", "Old.java", "New.java"]);
repo.git(&["mv", "Case.java", "CASE.java"]);
// Renames list their new path only.
let listed = changed(&mgr, &task.id).await;
assert_eq!(listed, ["CASE.java", "New.java"], "{eol:?}");
mgr.rollback_changes(&task.id, Some(listed)).await.unwrap();
assert_eq!(repo.read("Old.java"), "class Old {}\n", "{eol:?}");
assert!(!repo.dir.join("New.java").exists(), "{eol:?}");
assert_eq!(repo.read("Case.java"), "class Case {}\n", "{eol:?}");
let names: Vec<String> = std::fs::read_dir(&repo.dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".java"))
.collect();
assert!(!names.contains(&"CASE.java".to_owned()), "{eol:?}: {names:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
for eol in EOLS {
let repo = Checkout::new(eol);
repo.write("mine.txt", "mine\n");
let (mgr, task) = repo.task().await;
repo.write("new.txt", "agent\n");
assert_eq!(changed(&mgr, &task.id).await, ["new.txt"], "{eol:?}");
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None, "{eol:?}");
mgr.rollback_changes(&task.id, None).await.unwrap();
assert!(!repo.dir.join("new.txt").exists(), "{eol:?}");
assert_eq!(repo.read("mine.txt"), "mine\n", "{eol:?}");
assert!(changed(&mgr, &task.id).await.is_empty(), "{eol:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
// 1. Whitelist auto-approves "cargo test" without waiting for manual respond().
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent);
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("run tests"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose yes"))),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(
e,
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("whitelisted")
)),
"{entries:?}"
);
// 2. Blacklist auto-rejects "cargo test" when matched by blocked_commands.
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec!["cargo test*".into()],
}));
let blocked_task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("run blocked"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &blocked_task.id, AgentStatus::Ready).await;
let blocked_entries = mgr.entries(&blocked_task.id, None).unwrap();
assert!(
blocked_entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose no"))),
"{blocked_entries:?}"
);
assert!(
blocked_entries.iter().any(|e| matches!(
e,
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("Blocked by policy")
)),
"{blocked_entries:?}"
);
}
+16 -394
Переглянути файл
@@ -14,18 +14,8 @@ pub(crate) struct Created {
pub base_commit: String,
}
/// The repository of `path`, free to execute: the host runs every agent command that
/// reaches a repository only in a trusted project.
fn discover(path: &Path) -> IdeResult<Option<Repo>> {
let repo = Repo::discover(path)?;
if let Some(repo) = &repo {
repo.set_execution_allowed(true);
}
Ok(repo)
}
fn repo_at(path: &Path) -> IdeResult<Repo> {
discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display())))
}
/// The current branch (or commit when detached) and its commit.
@@ -101,11 +91,10 @@ fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Res
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
/// Outside a repository there is nothing to compare against: no changes.
pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
let Some(repo) = Repo::discover(cwd)? else { return Ok(Vec::new()) };
let root = repo.root().to_path_buf();
let stats: std::collections::HashMap<String, (u32, u32)> =
repo.numstat_with_worktree(base)?.into_iter().map(|(p, a, r)| (p, (a, r))).collect();
let stat = |rel: &str| stats.get(rel).copied().unwrap_or((0, 0));
let stats = repo.numstat_with_worktree(base)?;
let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r));
let mut out: Vec<AgentChange> = repo
.diff_with_worktree(base)?
.into_iter()
@@ -114,236 +103,16 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
})
.collect();
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
for s in repo.status()? {
if s.unstaged == Some(ChangeKind::Untracked) && !tracked.contains(&s.rel_path) {
out.push(added(s.path, s.rel_path));
if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) {
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
/// Files renamed since `rev`, by their new repository-relative path: the old path, as a deletion
/// to roll back along with the rename.
pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String, AgentChange>> {
let Some(repo) = discover(cwd)? else { return Ok(BTreeMap::new()) };
let root = repo.root().to_path_buf();
Ok(repo
.diff_with_worktree(rev)?
.into_iter()
.filter_map(|f| {
let old = f.old_path.filter(|old| *old != f.path)?;
let source = AgentChange {
path: IpcPath(root.join(&old)),
rel_path: old,
kind: ChangeKind::Deleted,
added: 0,
removed: 0,
};
Some((f.path, source))
})
.collect())
}
fn added(path: IpcPath, rel_path: String) -> AgentChange {
let added = std::fs::read_to_string(&*path).map_or(0, |t| t.lines().count() as u32);
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
}
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
fn crlf_to_lf(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (i, &b) in bytes.iter().enumerate() {
if !(b == b'\r' && bytes.get(i + 1) == Some(&b'\n')) {
out.push(b);
}
}
out
}
/// Where a task works and what its changes are measured against.
pub(crate) enum TaskBase {
/// A worktree of its own: everything since the commit it was created from, the agent's
/// own commits included.
Worktree { cwd: PathBuf, base: String },
/// The project's checkout, where the user works and commits too. `start` is the commit it
/// started from (`None` in a repository without commits, or an imported session).
Local { cwd: PathBuf, start: Option<String> },
}
impl TaskBase {
pub(crate) fn cwd(&self) -> &Path {
match self {
Self::Worktree { cwd, .. } | Self::Local { cwd, .. } => cwd,
}
}
/// The files the task changed, less those changed before it started and untouched since.
pub(crate) fn changes(&self, before: &BTreeMap<String, Stamp>) -> IdeResult<Vec<AgentChange>> {
let (cwd, start) = match self {
Self::Worktree { cwd, base } => return Ok(without_untouched(changes(cwd, base)?, before)),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(Vec::new()) };
// What is uncommitted, as the Commit tool window lists it: commits made since the task
// started drop out.
let head = repo.branch_info()?.head_commit;
let listed = match &head {
Some(head) => changes(cwd, head)?,
None => {
let mut all: Vec<AgentChange> = repo
.status()?
.into_iter()
.filter(|s| s.path.is_file())
.map(|s| added(s.path, s.rel_path))
.collect();
all.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
all
}
};
let listed = without_untouched(listed, before);
let Some(start) = start.filter(|s| head.as_deref() != Some(*s)) else { return Ok(listed) };
// HEAD moved (a reset, a checkout, a pull): a file that is as it was when the task
// started is the user's doing, not the task's. Git compares tracked files, with its
// line-ending and other filters: the bytes on disk and in the commit differ under
// `core.autocrlf`.
let since_start: std::collections::HashSet<String> = repo
.diff_with_worktree(start)?
.into_iter()
.flat_map(|f| [Some(f.path), f.old_path].into_iter().flatten())
.collect();
let untracked: std::collections::HashSet<String> = repo
.status()?
.into_iter()
.filter(|s| s.unstaged == Some(ChangeKind::Untracked))
.map(|s| s.rel_path)
.collect();
Ok(listed
.into_iter()
.filter(|c| {
if before.contains_key(&c.rel_path) {
return true;
}
let Ok(Some(at_start)) = repo.file_bytes_at(&c.path.0, Some(start)) else { return true };
if !untracked.contains(&c.rel_path) {
return since_start.contains(&c.rel_path);
}
// Untracked since (a mixed reset): git doesn't diff it, so compare the bytes,
// CRLF on disk matching LF committed.
!std::fs::read(&c.path.0).is_ok_and(|now| now == at_start || crlf_to_lf(&now) == at_start)
})
.collect())
}
/// The revision the task's changed files go back to on rollback, and are compared with:
/// the commit the task started from, or the current `HEAD` once the user committed on top
/// of it, so those commits stay. `None` while the repository has no commits.
pub(crate) fn rev(&self) -> IdeResult<Option<String>> {
let (cwd, start) = match self {
Self::Worktree { base, .. } => return Ok(Some(base.clone())),
Self::Local { cwd, start } => (cwd, start.as_deref()),
};
let Some(repo) = discover(cwd)? else { return Ok(None) };
let head = repo.branch_info()?.head_commit;
// A start commit git no longer knows (garbage-collected) can't be gone back to.
let start = start.filter(|s| repo.merge_base(s, s).ok().flatten().is_some());
Ok(match (start, head) {
(Some(start), Some(head)) if start != head && repo.merge_base(start, &head)?.as_deref() != Some(start) => {
Some(start.to_owned())
}
(start, head) => head.or_else(|| start.map(str::to_owned)),
})
}
}
/// Copies the task's `changes` (against `base`) from the worktree `cwd` into the
/// local checkout `root`, uncommitted: the user reviews and commits them with the
/// VCS tools. Files the checkout changed itself are left alone and reported, so
/// accepting never destroys the user's own edits. Returns accepted and skipped paths.
pub(crate) fn accept(
root: &Path,
cwd: &Path,
base: &str,
changes: &[AgentChange],
) -> IdeResult<crate::AgentAcceptOutcome> {
let repo = repo_at(cwd)?;
// Change paths are repository-relative, and the task's project may be a folder inside it.
let local_root = discover(root)?.map_or_else(|| root.to_path_buf(), |r| r.root().to_path_buf());
let renamed_from: BTreeMap<String, String> =
repo.diff_with_worktree(base)?.into_iter().filter_map(|f| Some((f.path, f.old_path?))).collect();
// Brings one path of the checkout to the task's version, unless the checkout changed it itself.
let apply = |rel: &str| -> IdeResult<bool> {
let local = local_root.join(rel);
let in_task = repo.root().join(rel);
let base_bytes = repo.file_bytes_at(&in_task, Some(base))?;
let current = std::fs::read(&in_task).ok();
let local_bytes = std::fs::read(&local).ok();
if local_bytes == current {
return Ok(true);
}
// The checkout may hold the committed LF text with CRLF line ends (`core.autocrlf`).
let unchanged = match (&local_bytes, &base_bytes) {
(Some(now), Some(at_base)) => now == at_base || crlf_to_lf(now) == *at_base,
(now, at_base) => now == at_base,
};
if !unchanged {
return Ok(false);
}
match current {
Some(bytes) => {
if let Some(parent) = local.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local, bytes)?;
}
None => match std::fs::remove_file(&local) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.into()),
_ => {}
},
}
Ok(true)
};
let mut accepted = Vec::with_capacity(changes.len());
let mut skipped = Vec::new();
let skip = |path: &str| crate::AgentAcceptSkipped {
path: path.to_owned(),
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
};
for change in changes {
// A case-only rename on a case-insensitive disk: both names reach the same file, so
// rename it in place before bringing it to the task's version.
if let Some(old) = renamed_from.get(&change.rel_path)
&& old != &change.rel_path
&& old.to_lowercase() == change.rel_path.to_lowercase()
&& local_root.join(old).exists()
&& same_file(&local_root.join(old), &local_root.join(&change.rel_path))
{
std::fs::rename(local_root.join(old), local_root.join(&change.rel_path))?;
}
if !apply(&change.rel_path)? {
skipped.push(skip(&change.rel_path));
continue;
}
accepted.push(change.rel_path.clone());
// A rename also takes the file away from its old path (unless only its case changed).
if let Some(old) = renamed_from.get(&change.rel_path)
&& old.to_lowercase() != change.rel_path.to_lowercase()
&& !apply(old)?
{
skipped.push(skip(old));
}
}
Ok(crate::AgentAcceptOutcome { accepted, skipped })
}
/// Size and modification time of a file, `None` once it is gone.
pub(crate) type Stamp = Option<(u64, u128)>;
@@ -355,10 +124,7 @@ pub(crate) fn stamp(path: &Path) -> Stamp {
/// The files already changed in `cwd` against `HEAD`, stamped so later edits to them show.
pub(crate) fn snapshot(cwd: &Path) -> IdeResult<BTreeMap<String, Stamp>> {
// Paths and stamps only: no diff and no line counts, which read every untracked file
// and held up starting a task in a checkout with many of them.
let Some(repo) = Repo::discover(cwd)? else { return Ok(BTreeMap::new()) };
Ok(repo.status()?.into_iter().map(|s| (s.rel_path, stamp(&s.path))).collect())
Ok(changes(cwd, "HEAD")?.into_iter().map(|c| (c.rel_path, stamp(&c.path))).collect())
}
/// Drops the changes that were there before the task and haven't been touched since.
@@ -395,11 +161,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
/// it, or when there is no `rev`: the repository had no commits).
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
rev: Option<&str>,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
@@ -417,13 +183,10 @@ pub(crate) fn restore(
})?;
Some(std::fs::read(copy)?)
}
None => match rev {
Some(rev) => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(rev))?
}
None => None,
},
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
@@ -441,7 +204,7 @@ pub(crate) fn restore(
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
let Some(repo) = discover(cwd)? else { return Ok(None) };
let Some(repo) = Repo::discover(cwd)? else { return Ok(None) };
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
@@ -628,145 +391,4 @@ mod tests {
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
assert!(elsewhere.join("keep.txt").exists());
}
fn accept_repo() -> (tempfile::TempDir, PathBuf, Created) {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
std::fs::write(repo.join("gone.txt"), "bye\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
(tmp, repo, created)
}
fn listed(cwd: &Path, base: &str) -> Vec<AgentChange> {
without_untouched(changes(cwd, base).unwrap(), &BTreeMap::new())
}
#[test]
fn accept_moves_a_renamed_file_and_maps_paths_from_a_project_folder() {
let (_tmp, repo, created) = accept_repo();
std::fs::create_dir(created.path.join("app")).unwrap();
git(&created.path, &["mv", "a.txt", "app/moved.txt"]);
std::fs::write(created.path.join("app/new.txt"), "n\n").unwrap();
git(&created.path, &["add", "."]);
git(&created.path, &["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "move"]);
// The task's project is the repository's `app` folder.
let project = repo.join("app");
std::fs::create_dir(&project).unwrap();
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&project, &created.path, &created.base_commit, &changes).unwrap();
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
assert_eq!(std::fs::read_to_string(repo.join("app/moved.txt")).unwrap(), "one\ntwo\n");
assert_eq!(std::fs::read_to_string(repo.join("app/new.txt")).unwrap(), "n\n");
assert!(!repo.join("a.txt").exists(), "the rename's old path is gone");
assert!(!repo.join("app/app").exists());
}
#[test]
fn accept_treats_a_crlf_checkout_of_the_base_as_unchanged() {
let (_tmp, repo, created) = accept_repo();
// The checkout holds the committed text with CRLF line ends, as core.autocrlf leaves it.
std::fs::write(repo.join("a.txt"), "one\r\ntwo\r\n").unwrap();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
git(&created.path, &["mv", "gone.txt", "kept.txt"]);
std::fs::write(repo.join("gone.txt"), "bye\r\n").unwrap();
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
assert!(repo.join("kept.txt").exists() && !repo.join("gone.txt").exists());
}
#[test]
fn accept_applies_a_case_only_rename() {
let (_tmp, repo, created) = accept_repo();
git(&created.path, &["mv", "a.txt", "A.txt"]);
let changes = listed(&created.path, &created.base_commit);
let outcome = accept(&repo, &created.path, &created.base_commit, &changes).unwrap();
assert!(outcome.skipped.is_empty(), "{:?}", outcome.skipped);
let names: Vec<_> = std::fs::read_dir(&repo)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|n| n.eq_ignore_ascii_case("a.txt"))
.collect();
assert_eq!(names, ["A.txt"]);
}
#[test]
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
std::fs::write(created.path.join("new.txt"), "x\n").unwrap();
std::fs::remove_file(created.path.join("gone.txt")).unwrap();
let outcome =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert!(outcome.skipped.is_empty());
assert_eq!(outcome.accepted, ["a.txt", "gone.txt", "new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "x\n");
assert!(!repo.join("gone.txt").exists());
// Accepting again changes nothing: the checkout already matches.
let again =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert!(again.skipped.is_empty());
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
let head = || head_of(&repo).unwrap().1;
let commit = |name: &str| {
std::fs::write(repo.join(name), "x\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", name]);
head_of(&repo).unwrap().1
};
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
let first = commit("a.txt");
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
let start = commit("b.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
let ahead = commit("c.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
git(&repo, &["reset", "-q", "--soft", &first]);
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
git(&repo, &["checkout", "-q", "-b", "side"]);
commit("d.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
let gone = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
assert_eq!(worktree.rev().unwrap(), Some(start));
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
std::fs::write(created.path.join("gone.txt"), "bye\nbye\n").unwrap();
std::fs::write(repo.join("a.txt"), "one\nMINE\n").unwrap();
let outcome =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert_eq!(outcome.accepted, ["gone.txt"]);
assert_eq!(outcome.skipped.len(), 1);
assert_eq!(outcome.skipped[0].path, "a.txt");
assert!(!outcome.skipped[0].reason.is_empty());
// The user's own edit survives; the other file comes over.
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
}
}
+1 -31
Переглянути файл
@@ -266,11 +266,6 @@ pub(super) fn maven_steps(
BuildKind::Rebuild => &["clean", "test-compile"],
BuildKind::Artifacts => &["package", "-DskipTests"],
};
// The IDE's compile builds are not coverage runs. JaCoCo's offline `instrument`
// rewrites target/classes in `process-classes`; `jacoco.skip` prevents that in this
// build. It does not restore classes instrumented by an earlier build: Rebuild's
// `clean` removes those, while an incremental Build can still reuse them.
let skip_jacoco = matches!(kind, BuildKind::Build | BuildKind::Rebuild);
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
if tops.is_empty() {
return Err(IdeError::not_found("no Maven project found"));
@@ -283,9 +278,6 @@ pub(super) fn maven_steps(
Format::Maven,
);
s.args.extend(p.options.iter().cloned());
if skip_jacoco {
s.args.push("-Djacoco.skip=true".into());
}
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
@@ -1049,22 +1041,6 @@ mod tests {
std::fs::write(p, text).unwrap();
}
#[test]
fn maven_compile_steps_skip_jacoco_but_artifacts_do_not() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("demo");
write(&root, "pom.xml", "<project><artifactId>demo</artifactId></project>");
let model = crate::load(&root, &tmp.path().join("cache"), None);
for (kind, expected) in [(BuildKind::Build, true), (BuildKind::Rebuild, true), (BuildKind::Artifacts, false)] {
let plan = plan(&root, &model, kind, None, None).unwrap();
assert_eq!(
plan.steps[0].args.iter().any(|arg| arg == "-Djacoco.skip=true"),
expected,
"{kind:?}: {plan:?}"
);
}
}
#[test]
fn plans_per_build_system() {
let tmp = tempfile::tempdir().unwrap();
@@ -1081,10 +1057,7 @@ mod tests {
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(
p.steps[0].args,
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "test-compile"]
);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(
p.steps[0].args,
@@ -1092,7 +1065,6 @@ mod tests {
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-Djacoco.skip=true",
"-pl",
"core",
"-am",
@@ -1381,7 +1353,6 @@ mod tests {
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
@@ -1398,7 +1369,6 @@ mod tests {
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
-1
Переглянути файл
@@ -536,7 +536,6 @@ mod tests {
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"-Djacoco.skip=true",
"test-compile"
]
);
-25
Переглянути файл
@@ -78,10 +78,6 @@ pub struct EditorAppearanceSettings {
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
/// Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
/// instead of their source, as IDEA's option of the same name. View › Active
/// Editor › Render Documentation Comments overrides this for one editor.
pub render_doc_comments: bool,
/// Distraction Free Mode: width of the centred text column, in characters.
pub distraction_free_width: u32,
}
@@ -104,7 +100,6 @@ impl Default for EditorAppearanceSettings {
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
render_doc_comments: false,
distraction_free_width: 80,
}
}
@@ -338,23 +333,6 @@ pub struct InlayHintsSettings {
pub code_vision_usages: bool,
/// Code vision: last author of a declaration (VCS blame) beside its usages.
pub code_vision_author: bool,
/// Code vision: "no usages" on declarations nothing uses (IDEA shows it; clicking searches everywhere).
pub code_vision_no_usages: bool,
/// Code vision: where the usages go, above the declaration or at the end of its line.
pub code_vision_usages_position: CodeVisionPosition,
/// Code vision: where the author goes, above the declaration or at the end of its line.
pub code_vision_author_position: CodeVisionPosition,
}
/// Editor › Inlay Hints › Code Vision position (IDEA's "Default position for metrics").
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum CodeVisionPosition {
/// A row of its own above the declaration.
#[default]
Top,
/// After the end of the declaration's first line.
Right,
}
impl Default for InlayHintsSettings {
@@ -366,9 +344,6 @@ impl Default for InlayHintsSettings {
disabled_languages: Vec::new(),
code_vision_usages: true,
code_vision_author: true,
code_vision_no_usages: true,
code_vision_usages_position: CodeVisionPosition::Top,
code_vision_author_position: CodeVisionPosition::Top,
}
}
}
-13
Переглянути файл
@@ -275,11 +275,6 @@ pub enum DebugEvent {
},
/// The session's capabilities changed after it started: fetch them again.
Capabilities,
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
/// `variables`; empty means all) is stale and has to be fetched again.
Invalidated {
areas: Vec<String>,
},
JvmBackend {
state: JvmBackendState,
},
@@ -335,12 +330,4 @@ mod tests {
);
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
}
#[test]
fn invalidated_event_json_shape() {
assert_eq!(
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
json!({"kind": "invalidated", "areas": ["variables"]})
);
}
}
+2 -10
Переглянути файл
@@ -12,16 +12,11 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
pub fn to_ipc(p: &Path) -> String {
#[cfg(windows)]
{
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
// The UI compares path strings; a drive letter is case-insensitive.
if s.as_bytes().get(1) == Some(&b':') {
s[..1].make_ascii_uppercase();
}
s
dunce::simplified(p).to_string_lossy().replace('\\', "/")
}
#[cfg(not(windows))]
{
@@ -149,8 +144,5 @@ mod tests {
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
}
}
+2 -30
Переглянути файл
@@ -108,32 +108,9 @@ impl Default for DeploymentSettings {
pub enum DeploymentType {
#[default]
Sftp,
Ftp,
/// FTP secured with `AUTH TLS` (explicit FTPS).
Ftps,
Local,
}
/// Host and login of an FTP / FTPS deployment server. The password is kept with
/// the saved passwords, under the server's id.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct FtpConnection {
pub host: String,
/// `None`: 21.
pub port: Option<u16>,
/// Empty: anonymous login.
pub user: String,
/// Passive mode (the client opens data connections); off: active mode.
pub passive: bool,
}
impl Default for FtpConnection {
fn default() -> Self {
Self { host: String::new(), port: None, user: String::new(), passive: true }
}
}
/// A deployment filesystem and how project folders map onto it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -143,8 +120,6 @@ pub struct DeploymentServer {
pub name: String,
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// FTP / FTPS servers.
pub ftp: FtpConnection,
/// Absolute remote folder the mappings are relative to; empty = the login directory.
pub root_path: String,
/// Web server URL of the root (Open in Browser on the deployed file).
@@ -250,11 +225,8 @@ mod deployment_tests {
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
assert_eq!(legacy.kind, DeploymentType::Sftp);
assert_eq!(legacy.ssh_config, "ssh");
assert!(legacy.ftp.passive);
let ftp =
FtpConnection { host: "ftp.example.com".into(), port: Some(2121), user: "web".into(), passive: false };
for kind in [DeploymentType::Sftp, DeploymentType::Ftp, DeploymentType::Ftps, DeploymentType::Local] {
let server = DeploymentServer { kind, ftp: ftp.clone(), ..legacy.clone() };
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
let server = DeploymentServer { kind, ..legacy.clone() };
assert_eq!(
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
server
+1 -23
Переглянути файл
@@ -63,8 +63,6 @@ pub struct Settings {
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Tools › Agents: Command Execution Policy.
pub agent_security: AgentSecuritySettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
@@ -263,9 +261,6 @@ pub enum WhileRunning {
pub struct McpServerSettings {
/// Serve the tools on 127.0.0.1 while the IDE runs.
pub enabled: bool,
/// Run the tools that change the project or start processes without asking first
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
pub run_without_confirmation: bool,
}
/// Absent profiles retain automatic discovery for existing projects.
@@ -413,7 +408,6 @@ impl Default for Settings {
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
agent_security: AgentSecuritySettings::default(),
plugins: BTreeMap::new(),
}
}
@@ -805,9 +799,6 @@ pub struct FileSettings {
pub file_types: BTreeMap<String, FileTypeAssociation>,
/// Override File Type: file (project-relative and `/`-separated, or absolute) → language id.
pub file_type_overrides: BTreeMap<String, String>,
/// File Types › Files opened in associated applications: file-name patterns
/// (`*.xlsx`) opened with the system application instead of an editor.
pub associated_applications: Vec<String>,
/// Compose detection choices, keyed by project-relative file path.
pub compose_file_overrides: BTreeMap<String, ComposeFileOverride>,
}
@@ -844,15 +835,6 @@ impl Default for FileSettings {
local_history_max_mb: 512,
file_types: BTreeMap::new(),
file_type_overrides: BTreeMap::new(),
associated_applications: vec![
"*.xlsx".into(),
"*.xls".into(),
"*.docx".into(),
"*.doc".into(),
"*.pptx".into(),
"*.ppt".into(),
"*.pdf".into(),
],
compose_file_overrides: BTreeMap::new(),
}
}
@@ -1203,8 +1185,6 @@ pub struct MavenSettings {
pub threads: String,
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
pub active_profiles: String,
/// Delegate IDE build/run actions to Maven: launches build with Maven (`javac`) instead of jdtls (ECJ).
pub delegate_build: bool,
}
impl MavenSettings {
@@ -1709,12 +1689,10 @@ mod maven_linkage_tests {
fn maven_runner_jre_defaults_and_round_trips() {
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
assert!(old.runner_jre.is_empty());
assert!(!old.delegate_build);
assert!(MavenSettings::default().runner_jre.is_empty());
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), delegate_build: true, ..old };
let settings = MavenSettings { runner_jre: "/opt/jdk-21".into(), ..old };
let encoded = serde_json::to_string(&settings).unwrap();
assert!(encoded.contains(r#""runnerJre":"/opt/jdk-21""#));
assert!(encoded.contains(r#""delegateBuild":true"#));
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
}
+2 -35
Переглянути файл
@@ -138,11 +138,8 @@ impl WorkspaceEdit {
continue;
}
*expected = expected.or(version);
// Repeats within one entry are intended (LSP inserts at one position
// apply in order); only an alias entry repeats what is already merged.
let merged = own.len();
for edit in edits {
if !own[..merged].contains(&edit) {
if !own.contains(&edit) {
own.push(edit);
}
}
@@ -280,40 +277,10 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(test)]
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
let path = PathBuf::from("missing-dir/main.ts");
let at = |line, text: &str| TextEdit {
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
new_text: text.into(),
};
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
for (entries, expected) in [
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
(
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
vec![nl.clone(), other.clone(), other.clone()],
),
] {
let mut workspace =
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
workspace.deduplicate();
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once, "{entries:?}");
}
}
#[cfg(unix)]
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
-57
Переглянути файл
@@ -192,33 +192,6 @@ pub struct IssueLink {
pub link: String,
}
/// Per-level colours for console log lines: a line whose level is detected
/// (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
/// prints itself win over it. Values are CSS colours, usually scheme tokens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LogLevelColors {
pub enabled: bool,
pub error: String,
pub warn: String,
pub info: String,
pub debug: String,
pub trace: String,
}
impl Default for LogLevelColors {
fn default() -> Self {
Self {
enabled: false,
error: "var(--ansi-red)".into(),
warn: "var(--ansi-yellow)".into(),
info: "var(--ansi-green)".into(),
debug: "var(--ansi-cyan)".into(),
trace: "var(--ansi-magenta)".into(),
}
}
}
/// Build, Execution, Deployment › Console.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -230,8 +203,6 @@ pub struct ConsoleSettings {
pub encoding: String,
/// Consecutive lines containing any of these are folded.
pub fold_patterns: Vec<String>,
/// Log lines take the colour of their level (Run/Debug consoles).
pub log_colors: LogLevelColors,
}
impl Default for ConsoleSettings {
@@ -248,7 +219,6 @@ impl Default for ConsoleSettings {
"at node:internal/".into(),
"<frozen importlib._bootstrap".into(),
],
log_colors: LogLevelColors::default(),
}
}
}
@@ -442,30 +412,3 @@ impl Default for ExternalTool {
}
}
}
/// Tools › Agents: Command Execution Policy (whitelist and blacklist patterns).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSecuritySettings {
/// Commands or patterns (`git status*`, `pwd`) automatically approved without prompting.
pub allowed_commands: Vec<String>,
/// Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected.
pub blocked_commands: Vec<String>,
}
impl Default for AgentSecuritySettings {
fn default() -> Self {
Self {
allowed_commands: Vec::new(),
blocked_commands: vec![
"rm -rf *".into(),
"rm -rf /".into(),
"mkfs*".into(),
"dd *".into(),
":(){ :|:& };:".into(),
"shutdown*".into(),
"reboot*".into(),
],
}
}
}
+3 -19
Переглянути файл
@@ -72,13 +72,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Inside the language server: reached through `debugServer`, never started.
"kotlin-lsp" => cfg(
"kotlin-lsp",
&[],
"tcp",
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
@@ -105,12 +98,9 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
),
"lldb-dap" => cfg(
"lldb-dap",
&[],
"stdio",
"Install LLVM's lldb-dap (brew install llvm), or on macOS use the one found by xcrun -f lldb-dap",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
@@ -127,12 +117,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"tcp",
"Install Microsoft java-debug (Settings › Languages & Frameworks › Language Servers)",
),
"bun-dap" => cfg(
"bun-dap",
&["${port}", "127.0.0.1"],
"tcp",
"To install bun-dap: git clone https://github.com/morf-bls/bun-dap, run bun install && bun run build, then place dist/bun-dap in your PATH",
),
_ => return None,
})
}
-12
Переглянути файл
@@ -342,16 +342,4 @@ mod tests {
assert_eq!(error_message(&msg), "cannot find a.py");
assert_eq!(error_message(&json!({"message": "nope"})), "nope");
}
#[test]
fn a_failed_launch_response_keeps_delves_too_old_error_verbatim() {
// The UI's repair offer keys off this exact wording (`missingAdapter`), so the
// conversion to the IPC error must not fold it into a generic message.
let message = "Failed to launch /p/api: Version of Delve is too old for Go version go1.27.0 \
(maximum supported version 1.26, suppress this error with --check-go-version=false)";
let e = ide_core::IdeError::from(DapError::Failed { command: "launch".into(), message: message.into() });
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
assert_eq!(e.message, format!("launch failed: {message}"));
assert!(e.message.contains("Version of Delve is too old for Go version go1.27.0"));
}
}
+1 -1
Переглянути файл
@@ -29,7 +29,7 @@ pub(crate) use scan::watch_for_attached_jvm;
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+5 -9
Переглянути файл
@@ -9,15 +9,11 @@ use std::{
pub const ADAPTER: &str = "kotlin-debug-adapter";
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
pub fn is_kotlin(adapter: &str) -> bool {
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
}
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
/// exist, so only its link holds resumes back until the attach is done.
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
/// until the attach is done.
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: true }
}
pub struct SourceMirror {
-147
Переглянути файл
@@ -1,147 +0,0 @@
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
//! the `debugServer` port it opens once the project is imported.
//!
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
//! after a step over a suspension point, loses every later stop at that breakpoint; while
//! stepping, breakpoints hit by other coroutines are skipped.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
pub const ADAPTER: &str = "kotlin-lsp";
/// The `adapterID` its debug server expects in `initialize`.
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
/// anything else, including a URI naming another host.
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
let rest = path.strip_prefix("file:")?;
let path = match rest.strip_prefix("//") {
Some(authority) => {
let slash = authority.find('/')?;
if !matches!(&authority[..slash], "" | "localhost") {
return None;
}
&authority[slash..]
}
None => rest,
};
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
// `/C:/p/App.kt` on Windows.
let decoded = match decoded.strip_prefix('/') {
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
_ => &decoded,
};
Some(PathBuf::from(decoded))
}
pub(crate) fn frame_name(name: &str) -> &str {
name.strip_prefix("at ").unwrap_or(name)
}
/// What kotlin-lsp's DAP server does unlike other adapters.
///
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
#[derive(Default)]
pub(crate) struct Quirks {
step_pending: AtomicBool,
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
threads: Mutex<(u64, HashSet<i64>)>,
}
impl Quirks {
/// Before `next`, `stepIn`, `stepOut` or a forced step.
pub fn step_sent(&self) {
self.step_pending.store(true, Ordering::Release);
}
/// Before `continue` or run to cursor, or after a step request failed.
pub fn resume_sent(&self) {
self.step_pending.store(false, Ordering::Release);
}
/// The reason to report for a `stopped` event.
pub fn stop_reason(&self, reason: String) -> String {
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
if stepped && reason == "breakpoint" { "step".into() } else { reason }
}
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
/// during that stop. A reply from an older stop changes nothing.
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
let mut threads = self.threads.lock();
if stop > threads.0 {
*threads = (stop, ids.into_iter().collect());
} else if stop == threads.0 {
threads.1.extend(ids);
}
}
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
let threads = self.threads.lock();
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn file_uris_become_paths() {
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
assert_eq!(source_path("/p/x.kt"), None);
}
#[test]
fn frame_names_lose_the_at_prefix() {
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
assert_eq!(frame_name("main"), "main");
}
#[test]
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
let quirks = Quirks::default();
quirks.step_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
quirks.step_sent();
quirks.resume_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
}
#[test]
fn threads_are_checked_against_the_current_stop() {
let quirks = Quirks::default();
quirks.saw_threads(3, [18]);
assert!(quirks.check_thread(3, true, 18).is_ok());
assert!(quirks.check_thread(3, true, 6).is_err());
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
assert!(quirks.check_thread(3, false, 6).is_ok());
}
#[test]
fn an_older_threads_reply_does_not_change_a_newer_stop() {
let quirks = Quirks::default();
quirks.saw_threads(4, [2]);
quirks.saw_threads(3, [1]);
assert!(quirks.check_thread(4, true, 1).is_err());
quirks.saw_threads(4, [7]);
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
quirks.saw_threads(5, [9]);
assert!(quirks.check_thread(5, true, 2).is_err());
}
}
+19 -55
Переглянути файл
@@ -15,7 +15,6 @@ pub mod connection;
pub mod jdwp;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -611,19 +610,9 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
.collect()
}
/// A step request's outcome; a failed one leaves no step pending, so the next stop keeps its reason.
fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Value, DapError> {
if sent.is_err()
&& let Some(quirks) = session.kotlin_lsp_quirks()
{
quirks.resume_sent();
}
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
@@ -632,10 +621,14 @@ fn remove_delve_binaries(cwd: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
// Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
@@ -988,7 +981,6 @@ impl DebugManager {
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
let caps = s
.conn
.request_timeout(
@@ -996,7 +988,7 @@ impl DebugManager {
json!({
"clientID": "openidea",
"clientName": "OpenIDEA",
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
"adapterID": s.adapter,
"locale": "en",
"linesStartAt1": true,
"columnsStartAt1": true,
@@ -1006,8 +998,7 @@ impl DebugManager {
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
// kotlin-lsp sends `invalidated` either way.
"supportsInvalidatedEvent": is_kotlin_lsp,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
"supportsArgsCanBeInterpretedByShell": false,
}),
@@ -1017,7 +1008,7 @@ impl DebugManager {
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
@@ -1041,7 +1032,7 @@ impl DebugManager {
jvm_memory::find_attached_jvm(s, port, finder).await;
}
}
if kotlin::is_kotlin(&s.adapter) {
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
@@ -1053,7 +1044,7 @@ impl DebugManager {
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
jvm_memory::watch_link(s, &link);
*s.jdwp_link.lock() = Some(link);
@@ -1390,8 +1381,6 @@ impl DebugManager {
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let session = self.session(s)?;
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
let stop = session.suspended.ticket();
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
let threads: Vec<ThreadInfo> = body
.get("threads")
@@ -1405,15 +1394,11 @@ impl DebugManager {
.collect()
})
.unwrap_or_default();
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
}
Ok(threads)
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
let body = session
.conn
.request_timeout(
@@ -1422,15 +1407,11 @@ impl DebugManager {
REQUEST_TIMEOUT,
)
.await?;
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
if kotlin_lsp {
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
@@ -1542,10 +1523,6 @@ impl DebugManager {
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.resume_sent();
}
let ticket = session.suspended.ticket();
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// DAP: a response without the flag continued every thread.
@@ -1590,13 +1567,8 @@ impl DebugManager {
StepKind::Out => "stepOut",
};
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
step_settled(&session, sent)?;
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// A step runs its thread; other threads stopped by their own events stay stopped.
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
@@ -1615,8 +1587,6 @@ impl DebugManager {
force: bool,
) -> IdeResult<()> {
let session = self.session(s)?;
// Before touching breakpoints: a refused `continue` would leave them replaced.
session.check_thread(thread_id)?;
if force {
self.suspend_breakpoints(&session, Some(&path)).await?;
}
@@ -1691,7 +1661,6 @@ impl DebugManager {
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
self.suspend_breakpoints(&session, None).await?;
self.step(s, thread_id, StepKind::Over).await
}
@@ -1699,16 +1668,11 @@ impl DebugManager {
/// Step into a specific call of the current line (Smart Step Into).
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
let sent = session
session
.conn
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
.await;
step_settled(&session, sent)?;
.await?;
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
+1 -34
Переглянути файл
@@ -3,7 +3,6 @@
use crate::adapter::AdapterSpec;
use crate::connection::{Connection, DapError, Incoming};
use crate::kotlin_lsp;
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
use ide_core::event::DebugEvent;
#[cfg(unix)]
@@ -104,8 +103,6 @@ pub(crate) struct Session {
pub jvm_located: watch::Sender<bool>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
pub(crate) quirks: kotlin_lsp::Quirks,
}
/// Which threads of the program are suspended: each from its `stopped` event until a
@@ -255,19 +252,6 @@ impl Session {
jvm_located: watch::channel(false).0,
kotlin_mirror: Mutex::new(None),
jvm_memory: tokio::sync::Mutex::default(),
quirks: kotlin_lsp::Quirks::default(),
}
}
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
}
/// Call before sending a request for thread `id`.
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
match self.kotlin_lsp_quirks() {
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
None => Ok(()),
}
}
@@ -299,11 +283,6 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if self.adapter == kotlin_lsp::ADAPTER
&& let Some(path) = kotlin_lsp::source_path(path)
{
return Some(path);
}
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
@@ -718,11 +697,7 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
"stopped" => {
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
s.suspended.stopped(int("threadId"), all);
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
if let Some(quirks) = s.kotlin_lsp_quirks() {
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
reason = quirks.stop_reason(reason);
}
let reason = str_("reason").unwrap_or_else(|| "pause".into());
mgr.restore_breakpoints(s).await;
s.emit(DebugEvent::Stopped {
thread_id: int("threadId").unwrap_or(0),
@@ -824,14 +799,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
change_type: crate::hot_code_replace_type(body.get("changeType")),
message: str_("message").unwrap_or_default(),
}),
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
"invalidated" => s.emit(DebugEvent::Invalidated {
areas: body
.get("areas")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+6 -291
Переглянути файл
@@ -126,22 +126,8 @@ struct Script {
continued_after_stop: bool,
/// Close the connection when a `pause` request arrives (the adapter dies).
hang_up_on_pause: bool,
/// Close the connection at the first request after the first `stopped` event.
hang_up_after_stop: bool,
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
continue_reply: Option<Value>,
/// Answer `continue` and stop at a breakpoint again, instead of letting the program exit.
stop_after_continue: bool,
/// The `reason` of the stop a `next` ends with (`step` when unset; kotlin-lsp says `breakpoint`).
step_reason: Option<&'static str>,
/// Refuse `next`, then stop at a breakpoint (one the program hit meanwhile).
refuse_next: bool,
/// Send `invalidated { areas: ["variables"] }` right after the first `stopped` event.
invalidated_after_stop: bool,
/// On `threads`: stop again on thread 2 first, then answer with thread 1 of the earlier stop.
stop_during_threads: bool,
/// Name of the first stack frame (`main` when unset).
frame_name: Option<&'static str>,
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
delay_initialize: Option<Duration>,
initialized_delay: Duration,
@@ -170,14 +156,7 @@ impl Default for Script {
hold_set_breakpoints: 0,
continued_after_stop: false,
hang_up_on_pause: false,
hang_up_after_stop: false,
continue_reply: None,
stop_after_continue: false,
step_reason: None,
refuse_next: false,
invalidated_after_stop: false,
stop_during_threads: false,
frame_name: None,
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
@@ -223,7 +202,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
macro_rules! send {
($v:expr) => {{
@@ -244,9 +222,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if msg["type"] == "response" {
continue;
}
if script.hang_up_after_stop && stopped {
break;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => {
if let Some(delay) = script.delay_initialize {
@@ -345,18 +320,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}}));
}
"next" if script.refuse_next => {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "next", "success": false, "message": "refused"})
);
// Lets the client handle the refusal before the stop.
tokio::time::sleep(Duration::from_millis(50)).await;
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1}}));
}
"next" => {
ok!(msg, Value::Null);
let reason = script.step_reason.unwrap_or("step");
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
}
"setVariable" => {
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
@@ -392,12 +358,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
stopped = true;
if script.invalidated_after_stop {
send!(
json!({"type": "event", "event": "invalidated", "body": {"areas": ["variables"], "threadId": 1}})
);
}
if script.continued_after_stop {
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
}
@@ -405,19 +365,11 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
);
}
"threads" if script.stop_during_threads => {
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 2, "allThreadsStopped": true}})
);
// Lets the client handle the stop before it reads the reply.
tokio::time::sleep(Duration::from_millis(50)).await;
ok!(msg, json!({"threads": [{"id": 1, "name": "\"main\"@1"}]}));
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": script.frame_name.unwrap_or("main"), "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -441,12 +393,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
]})
),
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
"continue" if script.stop_after_continue => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
}
"continue" => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
@@ -1472,7 +1418,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -2096,20 +2041,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
@@ -2456,13 +2401,6 @@ fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConf
}
}
#[test]
fn link_options_hold_only_for_fwcd() {
assert!(kotlin::link_options(kotlin::ADAPTER).hold_resumes);
assert!(!kotlin::link_options(kotlin_lsp::ADAPTER).hold_resumes);
assert!(!kotlin::link_options("x").hold_resumes);
}
#[tokio::test]
async fn kotlin_session_installs_link_inspector() {
let h = harness(Script::default());
@@ -2534,229 +2472,6 @@ async fn closing_the_session_drops_the_link() {
assert!(session.jvm_inspector.lock().is_none());
}
/// kotlin-lsp's debugger, reached through the language server's `debugServer` port (the fake ignores it).
fn kotlin_lsp_attach(project: &std::path::Path) -> LaunchConfig {
LaunchConfig {
adapter: kotlin_lsp::ADAPTER.into(),
request: "attach".into(),
cwd: Some(project.to_owned().into()),
extra_json: Some(
json!({"debugServer": 4711, "hostName": "127.0.0.1", "port": 5005, "projectRoot": project}).to_string(),
),
..config()
}
}
#[tokio::test]
async fn kotlin_lsp_initializes_as_intellij_jvm() {
let project = tempfile::tempdir().unwrap();
for (config, adapter_id, invalidated) in [
(kotlin_lsp_attach(project.path()), "intellij_jvm", true),
(kotlin_attach(project.path(), "127.0.0.1", 5005), "kotlin-debug-adapter", false),
] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
let init = &h.request("initialize")["arguments"];
assert_eq!(init["adapterID"], adapter_id);
assert_eq!(init["supportsInvalidatedEvent"], invalidated, "{adapter_id}");
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn kotlin_lsp_gets_real_paths_and_its_link_holds_nothing() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], json!(path));
let sent = &h.request("attach")["arguments"];
assert_eq!(sent["projectRoot"], json!(project.path()));
assert!(sent.get("debugServer").is_none());
assert_eq!(sent["hostName"], "127.0.0.1");
let link_port = sent["port"].as_u64().unwrap();
assert!(link_port != 0 && link_port != 5005);
let session = h.manager.session(id).unwrap();
assert!(session.kotlin_mirror.lock().is_none());
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
assert_eq!(session.attach_target.get(), Some(&("127.0.0.1".to_owned(), 5005)));
assert!(!kotlin::link_options(&session.adapter).hold_resumes);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_waits_for_the_configuration_done_reply() {
let project = tempfile::tempdir().unwrap();
// fwcd never answers it (`kotlin_configures_without_configuration_done_response`).
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let fwcd = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005));
let id = tokio::time::timeout(Duration::from_secs(2), fwcd).await.unwrap().unwrap();
h.manager.stop(id).await.unwrap();
tokio::time::pause();
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let started = tokio::time::Instant::now();
let err = h.start(kotlin_lsp_attach(project.path())).await.unwrap_err();
assert!(started.elapsed() >= REQUEST_TIMEOUT, "{:?}", started.elapsed());
assert!(err.message.contains("configurationDone"), "{err:?}");
assert!(h.manager.sessions(None).is_empty());
}
#[tokio::test]
async fn kotlin_lsp_sessions_have_the_memory_tab() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn losing_the_kotlin_lsp_connection_ends_the_session() {
let h = harness(Script { hang_up_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let session = h.manager.session(id).unwrap();
assert!(session.jdwp_link.lock().is_some());
let _ = session.conn.request_timeout("threads", Value::Null, Duration::from_secs(2)).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty());
assert!(session.jdwp_link.lock().is_none());
assert!(session.jvm_inspector.lock().is_none());
}
/// The reasons of the first `count` stops, once that many arrived.
async fn stop_reasons(h: &Harness, count: usize) -> Vec<String> {
for _ in 0..200 {
let reasons: Vec<String> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Stopped { reason, .. }, .. } => Some(reason.clone()),
_ => None,
})
.collect();
if reasons.len() >= count {
return reasons;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("{count} stops not received; got {:?}", h.events.lock());
}
#[tokio::test]
async fn a_step_stop_is_reported_as_a_step() {
let project = tempfile::tempdir().unwrap();
for (config, reason) in [(kotlin_lsp_attach(project.path()), "step"), (config(), "breakpoint")] {
let h = harness(Script { step_reason: Some("breakpoint"), ..Script::default() });
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", reason]);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_breakpoint_after_continue_stays_a_breakpoint() {
let h = harness(Script { step_reason: Some("breakpoint"), stop_after_continue: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
stop_reasons(&h, 2).await;
// The fake ends `stepIn` with no stop: the `continue` after it decides the next one.
h.manager.step(id, 1, StepKind::Into).await.unwrap();
h.manager.resume(id, 1).await.unwrap();
assert_eq!(stop_reasons(&h, 3).await, ["breakpoint", "step", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_breakpoint_after_a_refused_step_stays_a_breakpoint() {
let h = harness(Script { refuse_next: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(h.manager.step(id, 1, StepKind::Over).await.is_err());
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_thread_from_an_earlier_stop_is_refused() {
let project = tempfile::tempdir().unwrap();
for (config, kotlin_lsp) in [(kotlin_lsp_attach(project.path()), true), (config(), false)] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(h.manager.threads(id).await.unwrap().len(), 1);
let started = tokio::time::Instant::now();
let stale = h.manager.stack_trace(id, 99).await;
let asked =
|command: &str| h.log.lock().iter().any(|m| m["command"] == command && m["arguments"]["threadId"] == 99);
if kotlin_lsp {
assert!(stale.is_err() && started.elapsed() < Duration::from_millis(100), "{stale:?}");
assert!(h.manager.step(id, 99, StepKind::Over).await.is_err());
assert!(h.manager.resume(id, 99).await.is_err());
assert!(!asked("stackTrace") && !asked("next") && !asked("continue"));
} else {
assert!(stale.is_ok() && asked("stackTrace"), "{stale:?}");
}
assert!(h.manager.stack_trace(id, 1).await.is_ok());
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_threads_reply_from_before_a_stop_does_not_vouch_for_its_ids() {
let h = harness(Script { stop_during_threads: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let threads = h.manager.threads(id).await.unwrap();
// M3 maps kotlin-lsp's threads by these names.
assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "\"main\"@1"));
stop_reasons(&h, 2).await;
assert!(h.manager.stack_trace(id, 1).await.is_err());
assert!(h.manager.stack_trace(id, 2).await.is_ok());
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_frames_get_real_paths() {
let project = tempfile::tempdir().unwrap();
let at = "at com.x.AppKt.main(App.kt:13)";
for (config, path, name) in [
(kotlin_lsp_attach(project.path()), "/p/App.kt", "com.x.AppKt.main(App.kt:13)"),
(java_config("launch"), "file:///p/App.kt", at),
] {
let script = Script { source_path: Some("file:///p/App.kt".into()), frame_name: Some(at), ..Script::default() };
let h = harness(script);
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().map(|p| p.0.clone()), Some(PathBuf::from(path)));
assert_eq!(frames[0].name, name);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn invalidated_events_reach_the_client() {
let h = harness(Script { invalidated_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
match h.wait_event(|e| matches!(e, DebugEvent::Invalidated { .. })).await {
DebugEvent::Invalidated { areas } => assert_eq!(areas, ["variables"]),
other => panic!("{other:?}"),
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
-1
Переглянути файл
@@ -17,7 +17,6 @@ blake3.workspace = true
tokio.workspace = true
tracing.workspace = true
parking_lot.workspace = true
notify.workspace = true
futures.workspace = true
sqlx.workspace = true
tiberius.workspace = true
-42
Переглянути файл
@@ -12,8 +12,6 @@ pub mod secrets;
pub mod source;
pub mod split;
pub mod sql;
mod watch;
pub use watch::SourcesWatch;
pub use conn::{Held, Pool, QueryResult, ResultColumn};
pub use history::HistoryEntry;
@@ -237,9 +235,6 @@ pub struct DbManager {
/// Manual-commit data sources, each with the connection of its open
/// transaction (taken by the first statement after a commit / rollback).
sessions: Mutex<HashMap<String, Session>>,
/// The data sources as last read from disk, for [`DbManager::reconcile_sources`]
/// (`None` until [`DbManager::watch_sources`]).
on_disk: Mutex<Option<Vec<DataSource>>>,
}
type Session = Arc<tokio::sync::Mutex<Option<Held>>>;
@@ -367,7 +362,6 @@ impl DbManager {
tunnel_open: tokio::sync::Mutex::new(()),
ssh,
session_passwords: Mutex::default(),
on_disk: Mutex::default(),
sessions: Mutex::default(),
}
}
@@ -480,45 +474,9 @@ impl DbManager {
}
}
}
// Our own write is not reported as an edit on disk.
if let Some(known) = self.on_disk.lock().as_mut() {
*known = self.sources().unwrap_or(sources);
}
Ok(())
}
/// Watches the data source files (the project's and the global one) for edits made
/// outside the IDE; `on_change` runs on a background thread once they settle, and
/// should call [`DbManager::reconcile_sources`]. Stops when the result is dropped.
pub fn watch_sources(&self, on_change: Box<dyn Fn() + Send>) -> IdeResult<SourcesWatch> {
*self.on_disk.lock() = Some(self.sources().unwrap_or_default());
SourcesWatch::start(&self.root, self.global.as_ref().map(|(dir, _)| dir.as_path()), on_change)
}
/// Re-reads the data sources after their files changed on disk: connections of
/// removed or changed sources close, as on [`DbManager::save_sources`] (saved
/// passwords stay, the source may come back). Returns whether the list the tree
/// shows changed, or the files no longer parse (the tree reports the error).
pub async fn reconcile_sources(&self) -> bool {
let now = match self.sources() {
Ok(now) => now,
Err(e) => {
tracing::warn!("data sources: {}", e.message);
return true;
}
};
let before = self.on_disk.lock().replace(now.clone()).unwrap_or_default();
if before == now {
return false;
}
for o in &before {
if now.iter().find(|s| s.id == o.id) != Some(o) {
self.disconnect_source(&o.id).await;
}
}
true
}
/// 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<()> {
+1 -1
Переглянути файл
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
pub(crate) const FILE: &str = "data-sources.toml";
const FILE: &str = "data-sources.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
-151
Переглянути файл
@@ -1,151 +0,0 @@
//! Data source files edited outside the Database tool window (by hand, by a coding
//! agent, by `git checkout`) are picked up while the project is open, as IDEA does
//! for `dataSources.xml`. The project file tree's watcher is no help here: it never
//! reports a modification inside a gitignored `.openidea/`.
use crate::source::FILE;
use ide_core::{IdeError, IdeResult, project_dir};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak, mpsc};
use std::time::Duration;
/// How long a burst of writes (an editor's save, an agent's rewrite) settles before
/// the files are read.
const SETTLE: Duration = Duration::from_millis(200);
/// Watches `<root>/.openidea/data-sources.toml` and `<global>/data-sources.toml`;
/// stops when dropped.
pub struct SourcesWatch {
_watcher: Arc<Mutex<RecommendedWatcher>>,
}
impl SourcesWatch {
/// Calls `on_change` once a burst of changes to either file settled, on a
/// background thread.
pub fn start(root: &Path, global: Option<&Path>, on_change: Box<dyn Fn() + Send>) -> IdeResult<Self> {
let config: PathBuf = root.join(project_dir::DIR);
let (tx, rx) = mpsc::channel::<()>();
let relevant = {
let config = config.clone();
move |p: &Path| p.file_name().is_some_and(|n| n == FILE) || p == config
};
let watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(e) = res
&& e.paths.iter().any(|p| relevant(p))
{
let _ = tx.send(());
}
})
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
let watcher = Arc::new(Mutex::new(watcher));
{
let mut w = watcher.lock();
// The root (not recursively) sees `.openidea/` itself appear or go.
w.watch(root, RecursiveMode::NonRecursive)
.map_err(|e| IdeError::internal(format!("data sources watcher: {e}")))?;
if let Some(dir) = global.filter(|d| d.is_dir())
&& let Err(e) = w.watch(dir, RecursiveMode::NonRecursive)
{
tracing::warn!("global data sources watcher: {e}");
}
}
let mut watching = watch_config(&watcher, &config, false);
let weak: Weak<Mutex<RecommendedWatcher>> = Arc::downgrade(&watcher);
std::thread::Builder::new().name("openidea-db-sources-watch".into()).spawn(move || {
while rx.recv().is_ok() {
// Wait until the writes stop.
loop {
match rx.recv_timeout(SETTLE) {
Ok(()) => {}
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
let Some(watcher) = weak.upgrade() else { return };
watching = watch_config(&watcher, &config, watching);
drop(watcher);
on_change();
}
})?;
Ok(Self { _watcher: watcher })
}
}
/// Watches `.openidea/` once it exists (again: a removed folder loses its watch);
/// returns whether it is watched.
fn watch_config(watcher: &Mutex<RecommendedWatcher>, config: &Path, watching: bool) -> bool {
if !config.is_dir() {
return false;
}
if watching {
return true;
}
match watcher.lock().watch(config, RecursiveMode::NonRecursive) {
Ok(()) => true,
Err(e) => {
tracing::warn!("data sources watcher {}: {e}", config.display());
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
fn wait_for(count: &AtomicUsize, at_least: usize) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if count.load(Ordering::SeqCst) >= at_least {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
false
}
fn counting() -> (Arc<AtomicUsize>, Box<dyn Fn() + Send>) {
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
(count, Box::new(move || _ = c.fetch_add(1, Ordering::SeqCst)))
}
#[test]
fn reports_project_and_global_files_and_a_config_folder_created_later() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let (project, global) = (root.join("p"), root.join("app"));
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&global).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&project, Some(&global), on_change).unwrap();
// No `.openidea/` yet: it is created, then the file written into it.
std::fs::create_dir_all(project.join(".openidea")).unwrap();
assert!(wait_for(&count, 1));
let seen = count.load(Ordering::SeqCst);
std::fs::write(project.join(".openidea/data-sources.toml"), "").unwrap();
assert!(wait_for(&count, seen + 1), "project file");
let seen = count.load(Ordering::SeqCst);
std::fs::write(global.join("data-sources.toml"), "").unwrap();
assert!(wait_for(&count, seen + 1), "global file");
}
#[test]
fn ignores_other_files() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let (count, on_change) = counting();
let _watch = SourcesWatch::start(&root, None, on_change).unwrap();
std::fs::write(root.join(".openidea/settings.toml"), "").unwrap();
std::fs::write(root.join("main.py"), "").unwrap();
std::thread::sleep(SETTLE * 3);
assert_eq!(count.load(Ordering::SeqCst), 0);
}
}
-28
Переглянути файл
@@ -342,31 +342,3 @@ async fn applies_driver_properties_and_the_startup_script_on_connect() -> IdeRes
assert_eq!(db.schemas("s1").await?, vec!["main"]);
Ok(())
}
#[tokio::test]
async fn data_sources_edited_on_disk_close_their_connections() -> IdeResult<()> {
let (tmp, db) = setup().await;
std::fs::File::create(tmp.path().join("other.db")).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let _watch = db.watch_sources(Box::new(move || _ = tx.send(())))?;
db.pool("s1").await?;
assert_eq!(db.connected(), vec!["s1"]);
// Our own save is no edit on disk.
let mut sources = db.sources()?;
sources[0].name = "renamed".into();
db.save_sources(sources.clone()).await?;
assert!(!db.reconcile_sources().await);
// Edited outside the IDE (by hand, by an agent): the watcher reports it, and the
// connection made with the old settings closes.
db.pool("s1").await?;
sources[0].file = Some("other.db".into());
ide_db::source::save(tmp.path(), &sources)?;
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("the watcher reports the edit");
assert!(db.reconcile_sources().await);
assert!(db.connected().is_empty());
assert_eq!(db.sources()?[0].file.as_ref().map(|f| f.0.clone()), Some("other.db".into()));
assert!(!db.reconcile_sources().await);
Ok(())
}
+17 -44
Переглянути файл
@@ -136,19 +136,24 @@ pub struct DockerRegistry {
pub username: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerSettings {
#[serde(default)]
#[serde(default = "default_connections")]
pub connections: Vec<DockerConnection>,
#[serde(default)]
pub registries: Vec<DockerRegistry>,
}
/// Whether `c` is the connection older versions injected when nothing was configured:
/// Services lists only connections the user added, so this one is dropped on load.
fn is_injected_default(c: &DockerConnection) -> bool {
c.id == "default" && c.name == "Docker" && c.endpoint == DockerEndpoint::Default
impl Default for DockerSettings {
fn default() -> Self {
Self { connections: default_connections(), registries: Vec::new() }
}
}
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
fn default_connections() -> Vec<DockerConnection> {
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
}
const SETTINGS_FILE: &str = "docker.json";
@@ -156,17 +161,10 @@ const PASSWORDS_FILE: &str = "docker-passwords.json";
pub fn load_settings(data_dir: &Path) -> DockerSettings {
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
// A file written before the opt-in Services rework carries the injected default
// (saved along with a registry or another connection); drop it unless edited.
Ok(bytes) => serde_json::from_slice(&bytes)
.map(|mut s: DockerSettings| {
s.connections.retain(|c| !is_injected_default(c));
s
})
.unwrap_or_else(|e| {
tracing::warn!("{SETTINGS_FILE}: {e}");
DockerSettings::default()
}),
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("{SETTINGS_FILE}: {e}");
DockerSettings::default()
}),
Err(_) => DockerSettings::default(),
}
}
@@ -375,8 +373,7 @@ mod tests {
fn settings_round_trip_and_passwords() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
// Nothing configured: no connection is invented.
assert!(load_settings(dir).connections.is_empty());
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
let settings = DockerSettings {
connections: vec![DockerConnection {
id: "c1".into(),
@@ -398,32 +395,8 @@ mod tests {
assert!(auth_for(dir, "postgres").is_none());
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
assert_eq!(registry_password(dir, "r1"), None);
// An empty list stays empty.
// An empty list stays empty (the default connection is only for a missing file).
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
assert!(load_settings(dir).connections.is_empty());
}
#[test]
fn load_drops_the_injected_default_connection() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let injected =
|| DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default };
let registry =
DockerRegistry { id: "r1".into(), name: "GHCR".into(), address: "ghcr.io".into(), username: "me".into() };
// Saved untouched by an older version (e.g. along with a registry): dropped on load.
save_settings(dir, &DockerSettings { connections: vec![injected()], registries: vec![registry.clone()] })
.unwrap();
let loaded = load_settings(dir);
assert!(loaded.connections.is_empty());
assert_eq!(loaded.registries, vec![registry]);
// A connection the user renamed or re-pointed stays theirs.
let renamed = DockerConnection { name: "Local engine".into(), ..injected() };
save_settings(dir, &DockerSettings { connections: vec![renamed.clone()], registries: Vec::new() }).unwrap();
assert_eq!(load_settings(dir).connections, vec![renamed]);
let repointed =
DockerConnection { endpoint: DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }, ..injected() };
save_settings(dir, &DockerSettings { connections: vec![repointed.clone()], registries: Vec::new() }).unwrap();
assert_eq!(load_settings(dir).connections, vec![repointed]);
}
}
+2 -17
Переглянути файл
@@ -71,6 +71,7 @@ impl Capability {
match self {
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
Self::EditorWrite
| Self::ProjectWrite
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
@@ -79,14 +80,7 @@ impl Capability {
| Self::Secrets
| Self::InputEvents
| Self::Clipboard => Risk::Medium,
// A trusted project's own files name the programs the IDE starts (its settings, its
// build scripts), so writing them is running code.
Self::ProjectWrite
| Self::IdeCommands
| Self::Ipc(_)
| Self::Process(_)
| Self::FsRead(_)
| Self::FsWrite(_) => Risk::High,
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
}
}
@@ -408,15 +402,6 @@ mod tests {
assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates());
}
/// A trusted project's files name programs the IDE starts by itself: the git and language
/// server executables in `.openidea/settings.toml`, its build scripts. Writing them is
/// running code, and the install dialog says so.
#[test]
fn writing_project_files_is_a_high_risk_grant() {
assert_eq!(Capability::ProjectWrite.risk(), Risk::High);
assert!(!Capability::ProjectWrite.auto_migrates());
}
#[test]
fn host_patterns() {
assert!(host_matches("crates.io", "crates.io"));
+2 -29
Переглянути файл
@@ -34,7 +34,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.6.0";
pub const ENGINE_VERSION: &str = "1.5.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.
@@ -151,8 +151,6 @@ impl ActivationEvent {
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
/// `scheduled` comes from `on-schedule` instead.
pub const EVENT_KINDS: &[&str] = &[
"document-changed",
"websocket-ready",
"run-exited",
"terminal-exited",
"content-tab-action",
@@ -368,10 +366,6 @@ impl Manifest {
}
let mut seen = BTreeSet::new();
for event in &self.activation {
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "document-changed" | "websocket-ready"))
{
self.needs_api(&format!("`{event}`"), 6)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
@@ -588,8 +582,6 @@ mod tests {
ActivationEvent::parse("on-event:files-changed"),
Ok(ActivationEvent::Event("files-changed".into()))
);
assert!(ActivationEvent::parse("on-event:document-changed").is_ok());
assert!(ActivationEvent::parse("on-event:websocket-ready").is_ok());
let err = ActivationEvent::parse("on-event:keystroke").unwrap_err();
assert!(err.contains("unknown event kind") && err.contains("action-performed"), "{err}");
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
@@ -868,7 +860,7 @@ mod commands_api_tests {
mod net_ask_api_tests {
use super::*;
#[test]
fn api_1_5_declarations_require_api_1_5() {
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
@@ -883,23 +875,4 @@ mod net_ask_api_tests {
}
}
}
#[test]
fn realtime_events_require_api_1_6() {
for declaration in
["activation = [\"on-event:document-changed\"]", "activation = [\"on-event:websocket-ready\"]"]
{
for engine in ["^1.5", "^1.6"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.6" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.6"), "{engine}");
}
}
}
}
}
+3 -70
Переглянути файл
@@ -405,31 +405,10 @@ impl Index {
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
pub fn parse(text: &str) -> Result<Self, String> {
let (index, dropped) = Self::parse_dropping(text)?;
if let Some(first) = dropped.first() {
// One line per index, and a bounded one: whoever wrote the index chose the text.
let id: String = first.id.chars().take(80).collect();
tracing::warn!(
"index: {} entries are not offered, their id or version is not one a manifest may carry (first: {id:?})",
dropped.len()
);
}
Ok(index)
}
/// [`Index::parse`], with the entries it does not offer: those whose id or
/// version no manifest may carry, so no IDE could install them.
pub fn parse_dropping(text: &str) -> Result<(Self, Vec<IndexEntry>), String> {
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
if format == u64::from(INDEX_FORMAT) {
let mut index: Index = serde_json::from_value(value).map_err(|e| format!("index: {e}"))?;
// What no manifest may carry is not offered: the id and version also name files on disk.
let (offered, dropped) = std::mem::take(&mut index.plugins)
.into_iter()
.partition(|e| crate::validate_plugin_id(&e.id).is_ok() && e.semver().is_some());
index.plugins = offered;
return Ok((index, dropped));
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
}
if format < u64::from(INDEX_FORMAT) {
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
@@ -438,14 +417,13 @@ impl Index {
let items = value.get(field).and_then(serde_json::Value::as_array);
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
}
let index = Index {
Ok(Index {
format: u32::try_from(format).unwrap_or(u32::MAX),
publishers: each(&value, "publishers"),
plugins: Vec::new(),
revoked: each(&value, "revoked"),
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
};
Ok((index, Vec::new()))
})
}
/// Written in a format newer than this IDE reads: it lists no plugins.
@@ -930,51 +908,6 @@ mod tests {
}
}
#[test]
fn an_index_offers_only_the_ids_and_versions_a_manifest_may_carry() {
let invalid_ids = [
"../x",
"../../escape",
"acme/../x",
r"acme.\..\x",
"acme./../../../escape",
"/abs",
r"C:\x",
"ACME.x",
"acme.",
"acme..x",
"acme.x y",
"",
];
let invalid_versions =
["../1.0.0", "1.0.0/../../../escape", r"1.0.0\..\x", "1.0.0-..", " 1.0.0", "1.0", "latest", ""];
let valid = [("acme.x", "1.0.0"), ("acme.tools-2_b", "1.0.0-rc.1+b.2")];
let mut listed: Vec<(&str, &str)> = valid.to_vec();
listed.extend(invalid_ids.iter().map(|id| (*id, "1.0.0")));
listed.extend(invalid_versions.iter().map(|version| ("acme.x", *version)));
let plugins: Vec<serde_json::Value> = listed
.iter()
.map(|(id, version)| {
serde_json::json!({
"id": id, "name": "X", "version": version, "publisher": "acme",
"download": "x.oiplugin", "digest": "d", "engine": "^1.0",
})
})
.collect();
let text = serde_json::json!({ "format": INDEX_FORMAT, "plugins": plugins }).to_string();
let pairs = |entries: &[IndexEntry]| -> Vec<(String, String)> {
entries.iter().map(|e| (e.id.clone(), e.version.clone())).collect()
};
let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
pairs.iter().map(|(id, version)| ((*id).to_owned(), (*version).to_owned())).collect()
};
assert_eq!(pairs(&Index::parse(&text).unwrap().plugins), owned(&valid));
let (index, dropped) = Index::parse_dropping(&text).unwrap();
assert_eq!(pairs(&index.plugins), owned(&valid));
assert_eq!(pairs(&dropped), owned(&listed[valid.len()..]));
}
#[test]
fn a_newer_index_still_revokes_and_moves() {
let newer = format!(
+2 -10
Переглянути файл
@@ -102,14 +102,6 @@ fn parse<T>(r: serde_json::Result<T>) -> IdeResult<T> {
r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Protocol, format!("unexpected gh output: {e}")))
}
/// The repository of `root`, free to execute: the host runs the `gh_*` commands only in a
/// trusted project.
fn trusted_repo(root: &Path) -> IdeResult<Repo> {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
repo.set_execution_allowed(true);
Ok(repo)
}
/// Whether gh is installed and signed in, and which GitHub repository `root` maps to.
pub fn availability(root: &Path) -> GhAvailability {
if gh_executable().is_none() {
@@ -241,7 +233,7 @@ pub fn create(
return Err(IdeError::invalid("choose a base branch"));
}
if req.push {
let repo = trusted_repo(root)?;
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
repo.push(false, true, cancel, on_output)?;
}
let mut args = vec!["pr", "create", "--title", req.title.trim(), "--body", &req.body, "--base", req.base.trim()];
@@ -362,7 +354,7 @@ pub fn prepare_diff(
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<PrDiffRevisions> {
let repo = trusted_repo(root)?;
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
if !repo.has_commit(head_oid) || !repo.has_commit(base_oid) {
let remotes = repo.remotes()?;
let remote = remote_for(&remotes, name_with_owner)
-3
Переглянути файл
@@ -19,9 +19,6 @@ const CHUNK_SLOT_BITS: u32 = 17;
const WORD_BITS: u64 = 64;
const WORDS_PER_CHUNK: usize = (1 << CHUNK_SLOT_BITS) / WORD_BITS as usize;
// The bit table is matched in hot paths; boxing the big variant would only add
// an indirection to save stack space `IdMap` never holds by value in bulk.
#[allow(clippy::large_enum_variant)]
enum Repr {
Bits {
/// Chunk number of the first table entry.
-1
Переглянути файл
@@ -14,7 +14,6 @@ encoding_rs.workspace = true
ide-core.workspace = true
ide-mcp.workspace = true
futures.workspace = true
tokio-tungstenite.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
regex-syntax.workspace = true
+8 -58
Переглянути файл
@@ -22,7 +22,7 @@
mod templates;
use ide_extension::{Manifest, validate_plugin_id};
use ide_host::plugins::{PluginHost, market_href_reachable_from, plugin_icon, read_market_url, resolve_market_href};
use ide_host::plugins::{PluginHost, plugin_icon, read_market_url, resolve_market_href};
use ide_plugin::{Limits, PluginEngine};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -591,7 +591,7 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
let base = a.value("download-base").map(|b| b.trim_end_matches('/'));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
Ok(text) => parse_index(&text, &out.to_string_lossy())?,
Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
let publisher = match (a.value("publisher"), a.value("key")) {
@@ -722,22 +722,14 @@ fn register_key(
Ok(publishers)
}
/// The index in `text` (read from `file`), unless a newer `openidea-plugin` wrote it (rewriting it
/// would drop what this one can't read). Says which entries it does not keep: no IDE offers them.
fn parse_index(text: &str, file: &str) -> Result<Index, String> {
let (index, dropped) = Index::parse_dropping(text)?;
/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
fn current_format(index: Index, file: &str) -> Result<Index, String> {
if index.is_newer() {
return Err(format!(
"{file}: index format {} is newer than this tool writes; update openidea-plugin",
index.format
));
}
for e in &dropped {
eprintln!(
"warning: {file}: dropped {:?} {:?}: its id or version is not one a manifest may carry",
e.id, e.version
);
}
Ok(index)
}
@@ -748,15 +740,14 @@ fn cmd_merge(args: &[String]) -> Result<(), String> {
};
let keep = a.value("keep").ok_or("--keep is required")?;
let out = a.value("out").unwrap_or(base);
let mut index = parse_index(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?, base)?;
let mut index =
current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
let bytes = read_market_url(other, 16 << 20)?;
let mut theirs = parse_index(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?, other)?;
let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
let mut theirs = current_format(theirs, other)?;
// Their downloads are relative to their index, not to ours.
for e in &mut theirs.plugins {
e.download = resolve_market_href(other, &e.download);
if !market_href_reachable_from(other, &e.download) {
return Err(format!("{other}: the download of {} {} is not on the network", e.id, e.version));
}
}
let count = theirs.plugins.len();
index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?;
@@ -956,47 +947,6 @@ mod tests {
assert!(err.contains("unknown publisher"), "{err}");
}
/// An index read over the network is not merged when it names a download on this machine:
/// the merged index is a local one, which may.
#[test]
fn an_index_read_over_the_network_is_not_merged_with_local_downloads() {
use std::io::{BufRead, BufReader, Write};
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("index.json");
write_index(&Index { format: package::INDEX_FORMAT, ..Index::default() }, &base).unwrap();
let local = tmp.path().join("alice.dawn-1.0.0.oiplugin").to_string_lossy().into_owned();
let theirs = serde_json::json!({
"format": package::INDEX_FORMAT,
"publishers": [{ "id": "alice", "name": "Alice", "keys": [] }],
"plugins": [{
"id": "alice.dawn", "name": "Dawn", "version": "1.0.0", "publisher": "alice",
"download": local, "digest": "d", "engine": "^1.0",
}],
})
.to_string();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let other = format!("http://127.0.0.1:{}/index.json", listener.local_addr().unwrap().port());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
line.clear();
}
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{theirs}",
theirs.len()
);
}
});
let err = cmd_merge(&strings(&[base.to_str().unwrap(), &other, "--keep", "openidea"])).unwrap_err();
assert!(err.contains("is not on the network"), "{err}");
let merged = Index::parse(&std::fs::read_to_string(&base).unwrap()).unwrap();
assert!(merged.plugins.is_empty(), "{:?}", merged.plugins);
}
#[test]
fn the_index_carries_plugin_icons() {
let tmp = tempfile::tempdir().unwrap();
+26 -321
Переглянути файл
@@ -9,14 +9,6 @@ use std::path::{Path, PathBuf};
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
pub const OVERRIDE_FILE: &str = "local-data-dir.txt";
/// A pending move of the local data to a newly chosen directory, carried out
/// at the next start (before any project opens), like File › Invalidate Caches.
const MIGRATION_FILE: &str = "local-data-migration.json";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
@@ -25,202 +17,6 @@ pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
/// The directory to actually start with: the environment override, else a valid
/// custom directory from Settings, else the OS default. A stored but unusable
/// custom directory is an error, never a silent fallback.
pub fn startup_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
startup_resolve(std::env::var_os(LOCAL_DATA_ENV), stored_override(identifier), dirs::data_local_dir(), identifier)
}
fn startup_resolve(
var: Option<OsString>,
stored: Option<PathBuf>,
os: Option<PathBuf>,
identifier: &str,
) -> Result<PathBuf, String> {
if var.as_ref().is_some_and(|v| !v.is_empty()) {
return resolve_local(var, os, identifier);
}
let Some(root) = stored else { return resolve_local(None, os, identifier) };
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, "custom local data directory")
}
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
pub fn stored_override(identifier: &str) -> Option<PathBuf> {
stored_override_in(&override_path(identifier)?)
}
fn stored_override_in(path: &Path) -> Option<PathBuf> {
let text = fs::read_to_string(path).ok()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
/// The OS default local data directory (what clearing the custom directory
/// returns to), ignoring any stored override. The environment still applies.
pub fn default_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
local_data_dir(identifier)
}
/// Stores (`Some`) or clears (`None`) the custom local-data root, returning the
/// directory that takes effect after a restart. A pending custom migration to
/// anywhere else is dropped: only the caller schedules the matching one.
pub fn set_override(identifier: &str, root: Option<&Path>) -> Result<PathBuf, String> {
let Some(path) = override_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
set_override_in(&path, &migration_path(identifier), root, identifier)
}
fn set_override_in(
path: &Path,
migration: &Option<PathBuf>,
root: Option<&Path>,
identifier: &str,
) -> Result<PathBuf, String> {
if let Some(migration) = migration {
let _ = fs::remove_file(migration);
}
let Some(root) = root else {
if let Err(error) = fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
return Err(format!("cannot clear the custom local data directory: {error}"));
}
return local_data_dir(identifier);
};
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
let dir = resolve_root(root.to_path_buf(), identifier, "custom local data directory")?;
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot store the custom local data directory: {error}"));
}
if let Err(error) = fs::write(path, format!("{}\n", root.display())) {
return Err(format!("cannot store the custom local data directory: {error}"));
}
Ok(dir)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CustomMigration {
from: PathBuf,
to: PathBuf,
}
/// Schedules a move of the local data from `from` to `to` at the next start.
/// Returns false when there is nothing to move. The caller restarts the IDE.
pub fn schedule_custom_migration(identifier: &str, from: &Path, to: &Path) -> Result<bool, String> {
let Some(path) = migration_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
schedule_custom_migration_in(&path, from, to)
}
fn schedule_custom_migration_in(path: &Path, from: &Path, to: &Path) -> Result<bool, String> {
if from == to {
return Ok(false);
}
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot schedule the data move: {error}"));
}
let json = serde_json::to_vec(&CustomMigration { from: from.into(), to: to.into() })
.map_err(|error| format!("cannot schedule the data move: {error}"))?;
if let Err(error) = fs::write(path, json) {
return Err(format!("cannot schedule the data move: {error}"));
}
Ok(true)
}
/// Carries out a scheduled custom migration aimed at `local` (call at start-up,
/// before projects open). Entries that appear in the meantime are never
/// overwritten; a partially moved tree is retried at the next start.
pub fn apply_custom_migration(identifier: &str, local: &Path) {
let Some(path) = migration_path(identifier) else { return };
apply_custom_migration_in(&path, local);
}
fn apply_custom_migration_in(path: &Path, local: &Path) {
let Ok(bytes) = fs::read(path) else { return };
let Ok(job) = serde_json::from_slice::<CustomMigration>(&bytes) else {
let _ = fs::remove_file(path);
return;
};
if job.to != local || job.from == local {
let _ = fs::remove_file(path);
return;
}
move_entries(&job.from, local, &custom_entries(&job.from));
let leftover = custom_entries(&job.from).into_iter().any(|entry| job.from.join(entry).exists());
if !leftover {
let _ = fs::remove_file(path);
}
}
/// Entries of a local-data tree that move to a newly chosen directory.
/// Non-relocatable tool installs (virtual environments with absolute interpreter
/// paths) stay behind and are reinstalled on demand, as in `migrate_entries`;
/// so do registry agents, whose saved commands are absolute paths into `agents/tools`.
fn custom_entries(root: &Path) -> Vec<PathBuf> {
let mut entries = local_entries(root);
let logs = PathBuf::from("logs");
if !entries.contains(&logs) {
entries.push(logs);
}
entries
}
/// Moves the local-data tree from `from` to `to`, entry by entry. Same-volume
/// entries rename at once; cross-volume copies run in the background. Targets
/// created in the meantime are never merged into or overwritten.
fn move_entries(from: &Path, to: &Path, entries: &[PathBuf]) {
if from == to {
return;
}
let mut copies = Vec::new();
for entry in entries {
let source = from.join(entry);
let target = to.join(entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data move failed; keeping old data, using new location")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying data to the new location in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data");
}
}
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
@@ -229,14 +25,10 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, LOCAL_DATA_ENV)
}
fn resolve_root(root: PathBuf, identifier: &str, what: &str) -> Result<PathBuf, String> {
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -285,11 +77,6 @@ pub fn migrate(data: &Path, local: &Path) {
}
fn migrate_entries(data: &Path, local: &Path) {
move_entries(data, local, &local_entries(data));
}
/// The rebuildable entries of the legacy roaming tree that move to Local.
fn local_entries(data: &Path) -> Vec<PathBuf> {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
@@ -331,7 +118,31 @@ fn local_entries(data: &Path) -> Vec<PathBuf> {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
entries
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
@@ -786,110 +597,4 @@ mod tests {
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
#[test]
fn custom_override_round_trips_and_validates() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("local-data-dir.txt");
let migration = tmp.path().join("local-data-migration.json");
let id = "ai.test.openidea";
assert_eq!(stored_override_in(&file), None);
let root = tmp.path().join("custom-root");
let dir = set_override_in(&file, &Some(migration.clone()), Some(&root), id).unwrap();
assert_eq!(dir, root.join(id));
assert!(dir.is_dir());
assert_eq!(stored_override_in(&file), Some(root.clone()));
assert_eq!(startup_resolve(None, stored_override_in(&file), Some(tmp.path().join("os")), id).unwrap(), dir);
// A relative root is rejected before anything is written.
let before = fs::read_to_string(&file).unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(Path::new("relative")), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
assert_eq!(fs::read_to_string(&file).unwrap(), before);
// An unwritable root (an existing file) is rejected too.
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "").unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(&blocker), id).unwrap_err();
assert!(error.contains("cannot write"), "{error}");
// Clearing removes the file and returns the default.
let dir = set_override_in(&file, &Some(migration), None, id).unwrap();
assert!(!file.exists());
assert_eq!(dir, local_data_dir(id).unwrap());
assert_eq!(stored_override_in(&file), None);
}
#[test]
fn startup_prefers_the_environment_then_the_override() {
let tmp = tempfile::tempdir().unwrap();
let os = tmp.path().join("os");
let custom = tmp.path().join("custom");
let env = tmp.path().join("env");
let id = "ai.test.openidea";
let error = startup_resolve(None, Some(PathBuf::from("relative")), Some(os.clone()), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
// The environment wins over a stored override.
let dir = startup_resolve(Some(env.clone().into()), Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, env.join(id));
// Without the environment the override wins over the OS default.
let dir = startup_resolve(None, Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, custom.join(id));
// And without either the OS default applies.
let dir = startup_resolve(None, None, Some(os.clone()), id).unwrap();
assert_eq!(dir, os.join(id));
}
#[test]
fn custom_migration_moves_the_tree_and_clears_itself() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("local-data-migration.json");
let from = tmp.path().join("old-local");
let to = tmp.path().join("new-local");
fs::create_dir_all(from.join("index/p1")).unwrap();
fs::write(from.join("index/p1/symbols"), "index").unwrap();
fs::create_dir_all(from.join("cache/p1")).unwrap();
fs::write(from.join("cache/p1/build"), "cache").unwrap();
fs::create_dir_all(from.join("tools/demo/bin")).unwrap();
fs::write(from.join("tools/demo/bin/demo.cmd"), "payload").unwrap();
fs::write(from.join("history"), "roaming stays").unwrap();
// Nothing to move: no marker is written.
assert!(!schedule_custom_migration_in(&marker, &from, &from).unwrap());
assert!(!marker.exists());
assert!(schedule_custom_migration_in(&marker, &from, &to).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert_eq!(fs::read_to_string(to.join("index/p1/symbols")).unwrap(), "index");
assert_eq!(fs::read_to_string(to.join("cache/p1/build")).unwrap(), "cache");
assert!(to.join("tools/demo/bin/demo.cmd").exists());
assert!(!from.join("index").exists());
// Roaming content is not local data and stays behind.
assert_eq!(fs::read_to_string(from.join("history")).unwrap(), "roaming stays");
// A migration aimed elsewhere is dropped without touching anything.
fs::create_dir_all(from.join("lsp")).unwrap();
assert!(schedule_custom_migration_in(&marker, &from, &tmp.path().join("elsewhere")).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert!(from.join("lsp").exists());
assert!(!to.join("lsp").exists());
}
#[test]
fn custom_entries_cover_logs_and_keep_agent_installs() {
let tmp = tempfile::tempdir().unwrap();
let entries = custom_entries(tmp.path());
for expected in ["logs", "index", "cache", "lsp", "agents/registry.json"] {
assert!(entries.contains(&PathBuf::from(expected)), "{entries:?}");
}
// Registry agent files name their executables by absolute path.
assert!(!entries.iter().any(|e| e.starts_with("agents/tools")), "{entries:?}");
}
}
-5
Переглянути файл
@@ -51,12 +51,7 @@ impl Call {
pub async fn ask(&self, request: ide_core::event::McpUiRequest) -> IdeResult<()> {
self.host.mcp_requests.ask(self, request).await
}
/// Asks the user before a tool changes the project or starts a process, unless brave mode
/// is on — read from the IDE's own settings only, which a project's settings can't set.
pub async fn confirm(&self, subject: &str, detail: Option<String>) -> IdeResult<()> {
if self.host.global_settings.effective().mcp_server.run_without_confirmation {
return self.project.require_trust();
}
self.ask(ide_core::event::McpUiRequest::Confirm {
client: self.client.clone(),
tool: self.tool.clone(),
+28 -311
Переглянути файл
@@ -45,12 +45,26 @@ fn innermost_repo<T>(repos: Vec<(PathBuf, T)>, path: &Path) -> Option<(PathBuf,
// Read tools must never invoke repository-configured executables, including in Restricted Mode.
fn read_command(git: &str, root: &Path) -> std::process::Command {
let mut command = std::process::Command::new(git);
ide_vcs::restrict(&mut command);
command
.current_dir(root)
.args(["-c", "color.ui=false"])
.args([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=",
"-c",
"diff.external=",
"-c",
"core.attributesFile=",
"-c",
"submodule.recurse=false",
"-c",
"color.ui=false",
])
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_EXTERNAL_DIFF")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
@@ -58,32 +72,6 @@ fn read_command(git: &str, root: &Path) -> std::process::Command {
command
}
/// `-c` arguments that empty the clean and process filters the repository's own config names:
/// git runs them to compare a changed file with the index. Filters of the user's config stay
/// (Git LFS), as they are not the repository's choice.
fn filter_overrides(git: &str, root: &Path) -> IdeResult<Vec<String>> {
let mut cmd = read_command(git, root);
cmd.args(["config", "--null", "--list", "--show-scope"]);
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run git: {e}")))?;
if !out.status.success() {
return Err(IdeError::internal(String::from_utf8_lossy(&out.stderr).trim().to_owned()));
}
repository_filter_overrides(&out.stdout)
}
/// [`filter_overrides`] out of `listing`, the output of `git config --null --list --show-scope`.
fn repository_filter_overrides(listing: &[u8]) -> IdeResult<Vec<String>> {
let mut fields = listing.split(|b| *b == 0);
let mut own = Vec::new();
while let (Some(scope), Some(record)) = (fields.next(), fields.next()) {
if matches!(scope, b"local" | b"worktree") {
own.extend_from_slice(record);
own.push(0);
}
}
ide_vcs::filter_overrides_from(&own)
}
/// The branch HEAD is on (unborn too); `None` when HEAD is detached or git fails.
fn read_branch(git: &str, root: &Path) -> Option<String> {
let mut cmd = read_command(git, root);
@@ -179,10 +167,7 @@ fn git_executable(call: &Call) -> String {
/// `git status` of the repository at `root`, paths relative to it.
fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeResult<Vec<StatusEntry>> {
let mut cmd = read_command(git, root);
cmd.args(filter_overrides(git, root)?);
cmd.args(["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--find-renames"]);
// git asks a submodule with changes for its own status, which runs under the submodule's config.
cmd.arg("--ignore-submodules=dirty");
// git refuses `--ignored=matching` with `--untracked-files=no`: untracked files are dropped below.
cmd.arg(if untracked || ignored { "--untracked-files=all" } else { "--untracked-files=no" });
if ignored {
@@ -198,39 +183,6 @@ fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeRes
Ok(entries)
}
/// `git diff` of the repository at `root` (of `file` alone, when given): the index against HEAD
/// when `staged`, else disk against the index.
fn read_diff(
git: &str,
root: &Path,
file: Option<&Path>,
staged: bool,
cancel: &ide_core::CancellationToken,
) -> IdeResult<String> {
let mut cmd = read_command(git, root);
cmd.args(filter_overrides(git, root)?);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
// git asks a submodule with changes for its own status, and for its own diff when the
// repository's `diff.submodule` says so: both run under the submodule's config.
cmd.args(["--ignore-submodules=dirty", "--submodule=short"]);
if staged {
cmd.arg("--cached");
}
cmd.arg("--");
if let Some(file) = file {
cmd.arg(file.strip_prefix(root).map_err(|e| IdeError::invalid(e.to_string()))?);
}
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let out = cmd.output()?;
if !out.status.success() {
return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim()));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// A git repository in the project, for paths outside the repositories IDEA knows: the
/// closest folder with `.git` from `start` up to the project root.
fn find_git_root(root: &Path, start: &Path) -> Option<PathBuf> {
@@ -355,7 +307,18 @@ fn git_diff() -> Tool {
let staged = call.flag("staged", false)?;
let git = git_executable(&call);
let cancel = call.cancel.clone();
let text = call.host.blocking(move || read_diff(&git, &root, file.as_deref(), staged, &cancel)).await?;
let text = call.host.blocking(move || {
let mut cmd = read_command(&git, &root);
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
if staged { cmd.arg("--cached"); }
cmd.arg("--");
if let Some(file) = file { cmd.arg(file.strip_prefix(&root).map_err(|e| IdeError::invalid(e.to_string()))?); }
ide_core::process::prepare(&mut cmd);
if cancel.is_cancelled() { return Err(IdeError::cancelled()); }
let out = cmd.output()?;
if !out.status.success() { return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim())); }
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}).await?;
Ok(Output::text(text))
})
.param(Param::optional("repositoryPathRelativeToProject", ParamType::String, "Select the containing repository"))
@@ -379,252 +342,6 @@ mod tests {
assert!(e[3].conflicted() && !e[3].staged() && !e[3].unstaged());
}
/// Security audit lead 2 (variant): reading status or a diff must not run the repository's filters.
#[test]
fn status_and_diff_do_not_run_repository_filters() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let git = |args: &[&str]| {
let out = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "-q"]);
git(&["config", "user.email", "[email protected]"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
std::fs::write(root.join(".gitattributes"), "*.txt filter=marker\n").unwrap();
std::fs::write(root.join("a.txt"), "one\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "init"]);
git(&["config", "filter.marker.clean", "echo x > executed-marker; cat"]);
git(&["config", "filter.marker.process", "echo x > executed-marker; cat"]);
git(&["config", "filter.marker.required", "true"]);
let marker = root.join("executed-marker");
let mut ran = Vec::new();
// The size it has in the index, so git has to read the file to tell whether it changed.
std::fs::write(root.join("a.txt"), "two\n").unwrap();
let status = read_status("git", &root, false, false);
if marker.exists() {
ran.push("status");
}
let _ = std::fs::remove_file(&marker);
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap();
let diff = read_diff("git", &root, None, false, &ide_core::CancellationToken::new());
if marker.exists() {
ran.push("diff");
}
// Control: the fixture's filter really runs when git is used directly.
let _ = std::fs::remove_file(&marker);
std::fs::write(root.join("a.txt"), "six\n").unwrap();
let _ = std::process::Command::new("git").current_dir(&root).args(["status", "--porcelain"]).output();
assert!(marker.exists(), "control: plain `git status` did not run the filter, fixture is not live");
assert!(ran.is_empty(), "a read tool ran the repository's filter via: {ran:?}");
assert_eq!(status.unwrap().len(), 1);
let diff = diff.unwrap();
assert!(diff.contains("+three"), "{diff}");
}
#[test]
fn only_filters_of_the_repository_s_own_config_are_emptied() {
let listing = b"system\0filter.lfs.clean\ngit-lfs clean -- %f\0\
global\0filter.lfs.process\ngit-lfs filter-process\0\
global\0filter.lfs.required\ntrue\0\
local\0core.bare\nfalse\0\
local\0filter.a.clean\nx\0\
worktree\0filter.b.process\ny\0\
local\0filter.a.required\ntrue\0\
command\0filter.c.clean\nz\0";
assert_eq!(
repository_filter_overrides(listing).unwrap(),
["-c", "filter.a.clean=", "-c", "filter.b.process=", "-c", "filter.a.required=false"]
);
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git").current_dir(dir).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
/// A repository at `dir` with `a.txt` committed.
fn repository(dir: &Path, attributes: &[u8]) {
std::fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q"]);
git(dir, &["config", "user.email", "[email protected]"]);
git(dir, &["config", "user.name", "t"]);
git(dir, &["config", "commit.gpgsign", "false"]);
std::fs::write(dir.join(".gitattributes"), attributes).unwrap();
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-qm", "init"]);
}
/// A shell command that leaves `marker` behind and passes its input on.
fn leaves(marker: &Path) -> String {
format!("echo x > '{}'; cat", marker.to_string_lossy().replace('\\', "/"))
}
/// Whether a program of the fixture left `marker` behind; removes it.
fn left(marker: &Path) -> bool {
let was = marker.exists();
let _ = std::fs::remove_file(marker);
was
}
/// Reads the status and then the diff of `root` after rewriting `file`, then has plain git
/// read the status as a control. Each write keeps the size the file has in the index, so git
/// has to read it to tell whether it changed.
fn read_both(root: &Path, file: &Path, marker: &Path) -> (IdeResult<Vec<StatusEntry>>, IdeResult<String>) {
let mut ran = Vec::new();
std::fs::write(file, "two\n").unwrap();
let status = read_status("git", root, false, false);
if left(marker) {
ran.push("status");
}
std::fs::write(file, "six\n").unwrap();
let diff = read_diff("git", root, None, false, &ide_core::CancellationToken::new());
if left(marker) {
ran.push("diff");
}
std::fs::write(file, "ten\n").unwrap();
let _ = std::process::Command::new("git").current_dir(root).args(["status", "--porcelain"]).output();
assert!(left(marker), "control: plain `git status` ran no program of the fixture, it is not live");
assert!(ran.is_empty(), "a read tool ran a program of the repository via: {ran:?}");
(status, diff)
}
#[test]
fn status_and_diff_do_not_run_filters_of_any_repository_config() {
type Configure = fn(&Path, &str);
let cases: [(&str, Configure); 4] = [
("clean", |root, command| git(root, &["config", "filter.marker.clean", command])),
("process", |root, command| git(root, &["config", "filter.marker.process", command])),
("included file", |root, command| {
let included = format!("[filter \"marker\"]\n\tclean = \"{command}\"\n");
std::fs::write(root.join("extra.gitconfig"), included).unwrap();
git(root, &["config", "include.path", "../extra.gitconfig"]);
}),
("worktree config", |root, command| {
git(root, &["config", "extensions.worktreeConfig", "true"]);
git(root, &["config", "--worktree", "filter.marker.clean", command]);
}),
];
for (case, configure) in cases {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let marker = root.with_file_name("marker");
repository(&root, b"*.txt filter=marker\n");
configure(&root, &leaves(&marker));
let (status, diff) = read_both(&root, &root.join("a.txt"), &marker);
assert_eq!(status.unwrap().len(), 1, "{case}");
let diff = diff.unwrap();
assert!(diff.contains("+six"), "{case}: {diff}");
}
}
/// git splits `-c name=value` at the first `=` and takes no name that is not UTF-8 on every
/// platform, so such a filter cannot be emptied: the tools read nothing instead.
#[test]
fn status_and_diff_refuse_a_repository_filter_git_cannot_be_told_to_skip() {
for name in [&b"x=y"[..], &[0xE9]] {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let marker = root.with_file_name("marker");
repository(&root, &[b"*.txt filter=", name, b"\n"].concat());
let section =
[b"[filter \"", name, format!("\"]\n\tclean = \"{}\"\n", leaves(&marker)).as_bytes()].concat();
let config = root.join(".git").join("config");
std::fs::write(&config, [std::fs::read(&config).unwrap(), section].concat()).unwrap();
let (status, diff) = read_both(&root, &root.join("a.txt"), &marker);
assert!(status.is_err() && diff.is_err(), "{name:?}");
}
}
/// git asks a submodule for its own status to tell whether it is dirty, under the
/// submodule's config.
#[test]
fn status_and_diff_do_not_run_filters_of_a_submodule() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let (sub, marker) = (root.join("sub"), root.with_file_name("marker"));
repository(&root, b"");
repository(&sub, b"*.txt filter=marker\n");
git(&root, &["add", "sub"]);
git(&root, &["commit", "-qm", "submodule"]);
git(&sub, &["config", "filter.marker.clean", &leaves(&marker)]);
git(&root, &["config", "diff.ignoreSubmodules", "none"]);
git(&root, &["config", "submodule.sub.ignore", "none"]);
let (status, diff) = read_both(&root, &sub.join("a.txt"), &marker);
status.unwrap();
diff.unwrap();
}
/// `git diff` writes the index back when it finds a file whose stat changed and whose content
/// did not, and git runs the hooks a config names for `post-index-change` on that write.
#[test]
fn status_and_diff_do_not_run_hooks_of_the_repository_s_config() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let marker = root.with_file_name("marker");
repository(&root, b"");
git(&root, &["config", "hook.marker.event", "post-index-change"]);
let command = format!("echo x > '{}'", marker.to_string_lossy().replace('\\', "/"));
git(&root, &["config", "hook.marker.command", &command]);
let listed = std::process::Command::new("git")
.current_dir(&root)
.args(["hook", "list", "post-index-change"])
.output()
.unwrap();
if !String::from_utf8_lossy(&listed.stdout).contains("marker") {
return; // This git has no hooks named by a config.
}
// The content it has in the index, with a time of its own that is not the index's.
let touch = |seconds: u64| {
let file = std::fs::File::options().write(true).open(root.join("a.txt")).unwrap();
file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(seconds)).unwrap();
};
let mut ran = Vec::new();
touch(60);
let status = read_status("git", &root, false, false);
if left(&marker) {
ran.push("status");
}
touch(120);
let diff = read_diff("git", &root, None, false, &ide_core::CancellationToken::new());
if left(&marker) {
ran.push("diff");
}
touch(180);
let _ = std::process::Command::new("git").current_dir(&root).args(["--no-pager", "diff"]).output();
assert!(left(&marker), "control: plain `git diff` did not run the hook, fixture is not live");
assert!(ran.is_empty(), "a read tool ran a hook of the repository's config via: {ran:?}");
assert!(status.unwrap().is_empty());
assert_eq!(diff.unwrap(), "");
}
/// `diff.submodule=diff` has git run `git diff` in the submodule, under the submodule's config.
#[test]
fn diff_does_not_run_a_diff_driver_of_a_submodule() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap().join("repo");
let (sub, marker) = (root.join("sub"), root.with_file_name("marker"));
repository(&root, b"");
repository(&sub, b"*.txt diff=marker\n");
git(&root, &["add", "sub"]);
git(&root, &["commit", "-qm", "submodule"]);
std::fs::write(sub.join("a.txt"), "one\ntwo\n").unwrap();
git(&sub, &["commit", "-qam", "second"]);
git(&sub, &["config", "diff.marker.command", &leaves(&marker)]);
git(&root, &["config", "diff.submodule", "diff"]);
let diff = read_diff("git", &root, None, false, &ide_core::CancellationToken::new());
let ran = left(&marker);
let _ = std::process::Command::new("git").current_dir(&root).args(["--no-pager", "diff"]).output();
assert!(left(&marker), "control: plain `git diff` did not run the diff driver, fixture is not live");
assert!(!ran, "git_diff ran the submodule's diff driver");
let diff = diff.unwrap();
assert!(diff.contains("Subproject commit"), "{diff}");
assert_eq!(read_status("git", &root, false, false).unwrap().len(), 1);
}
#[cfg(unix)]
#[test]
fn paths_through_a_symlinked_root_compare_canonically() {
+4 -83
Переглянути файл
@@ -84,14 +84,6 @@ pub struct NotebookService {
registry: Arc<Mutex<Registry>>,
kernels: Arc<KernelManager>,
sink: SharedSink,
observer: parking_lot::RwLock<Option<Arc<dyn NotebookObserver>>>,
}
/// Learns each open notebook's contents after every change of them, and when it closes (the
/// language servers' copy of its code cells).
pub trait NotebookObserver: Send + Sync {
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot);
fn closed(&self, path: &Path);
}
impl NotebookService {
@@ -129,23 +121,7 @@ impl NotebookService {
}
}),
);
Arc::new(Self { registry, kernels: Arc::new(kernels), sink, observer: Default::default() })
}
pub fn set_observer(&self, observer: Arc<dyn NotebookObserver>) {
*self.observer.write() = Some(observer);
}
/// Called with the document's state locked, so observers see its changes in order.
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
if let Some(observer) = self.observer.read().clone() {
observer.changed(path, snapshot);
}
}
/// The file of an open notebook.
pub fn path_of(&self, id: &str) -> IdeResult<PathBuf> {
Ok(self.resolve(id)?.path.clone())
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
}
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
@@ -160,7 +136,6 @@ impl NotebookService {
}
let document = NotebookDocument::load(&path)?;
let initial = document.snapshot();
self.changed(&path, &initial);
let id = initial.id.clone();
registry.paths.insert(path.clone(), id.clone());
registry.documents.insert(
@@ -198,9 +173,7 @@ impl NotebookService {
let document = self.resolve(id)?;
let mut state = document.state.lock();
mutable(&state)?;
let snapshot = state.document.edit(version, edit)?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
state.document.edit(version, edit)
}
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
@@ -217,9 +190,7 @@ impl NotebookService {
if state.active.is_some() {
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
}
let snapshot = if redo { state.document.redo() } else { state.document.undo() }?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
if redo { state.document.redo() } else { state.document.undo() }
}
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
@@ -267,10 +238,7 @@ impl NotebookService {
snapshot(&document)
} else {
self.kernels.shutdown_kernel(&document.path).await?;
let mut state = document.state.lock();
let snapshot = state.document.reload()?;
self.changed(&document.path, &snapshot);
Ok(snapshot)
document.state.lock().document.reload()
}
}
.await;
@@ -316,10 +284,6 @@ impl NotebookService {
let mut registry = self.registry.lock();
registry.documents.remove(id);
registry.paths.remove(&document.path);
drop(registry);
if let Some(observer) = self.observer.read().clone() {
observer.closed(&document.path);
}
Ok(())
}
@@ -608,49 +572,6 @@ mod tests {
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
}
#[tokio::test]
async fn the_observer_follows_every_change_of_an_open_notebook_and_its_close() {
#[derive(Default)]
struct Seen(Mutex<Vec<String>>);
impl NotebookObserver for Seen {
fn changed(&self, path: &Path, snapshot: &NotebookSnapshot) {
let sources: Vec<&str> = snapshot.cells.iter().map(|c| c.source.as_str()).collect();
let name = path.file_name().unwrap().to_string_lossy();
self.0.lock().push(format!("{name}: {}", sources.join(" | ")));
}
fn closed(&self, path: &Path) {
self.0.lock().push(format!("closed {}", path.file_name().unwrap().to_string_lossy()));
}
}
let (dir, service, initial) = fixture();
let seen = Arc::new(Seen::default());
service.set_observer(seen.clone());
service.close(&initial.id).await.unwrap();
let opened = service.open(&dir.path().join("session.ipynb")).unwrap();
assert_eq!(
service.path_of(&opened.id).unwrap(),
dunce::canonicalize(dir.path().join("session.ipynb")).unwrap()
);
let edit = NotebookEdit::SetSource { cell: "second".into(), source: "print(x + 1)".into() };
let edited = service.edit(&opened.id, opened.version, edit).unwrap();
service.undo(&opened.id, false).unwrap();
// A second view of the open notebook changes nothing.
service.open(&dir.path().join("session.ipynb")).unwrap();
assert!(edited.version > opened.version);
service.close(&opened.id).await.unwrap();
assert_eq!(
*seen.0.lock(),
[
"closed session.ipynb",
"session.ipynb: x = 1 | print(x) | notes",
"session.ipynb: x = 1 | print(x + 1) | notes",
"session.ipynb: x = 1 | print(x) | notes",
"closed session.ipynb",
]
);
service.shutdown().await;
}
#[tokio::test]
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
let (dir, service, initial) = fixture();
+2 -36
Переглянути файл
@@ -5,9 +5,8 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
NewAgentTask,
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -45,24 +44,6 @@ pub fn set_env(
host.project(window)?.agents.set_builtin_env(&id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(host: &Host, window: &str, input: CustomAgentInput) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.save_custom_agent(input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(host: &Host, window: &str, id: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.remove_custom_agent(&id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(host: &Host, window: &str, text: String) -> IdeResult<Vec<AcpImportOutcome>> {
host.require_trust(window)?;
host.project(window)?.agents.import_acp_agents(&text)
}
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
@@ -316,18 +297,6 @@ pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Op
host.project(window)?.agents.rollback_changes(&task, paths).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
/// local checkout, uncommitted; files the checkout changed itself are left alone and reported.
pub async fn accept_changes(
host: &Host,
window: &str,
task: String,
paths: Option<Vec<String>>,
) -> IdeResult<AgentAcceptOutcome> {
host.require_trust(window)?;
host.project(window)?.agents.accept_changes(&task, paths).await
}
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
@@ -480,9 +449,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_rollback_changes", |c| async move {
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
d.add("agent_accept_changes", |c| async move {
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
+3 -5
Переглянути файл
@@ -17,11 +17,9 @@ pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u
blocking(move || ide_vfs::read_range(&path, offset, len)).await
}
/// An image file as a `data:` URL. A network path is read only from a share an open project is
/// on: the path may come from Markdown, and opening a share signs in to its server.
pub async fn fs_read_image(host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
let opened: Vec<PathBuf> = host.projects().iter().map(|project| project.root.clone()).collect();
blocking(move || ide_vfs::read_data_url(&path, &opened)).await
/// An image file as a `data:` URL.
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
blocking(move || ide_vfs::read_data_url(&path)).await
}
/// Commands of this module the remote server answers.
-4
Переглянути файл
@@ -1,6 +1,3 @@
mod kotlin;
#[cfg(test)]
mod kotlin_tests;
mod output;
use crate::Host;
@@ -44,7 +41,6 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
})
.await?;
}
config = kotlin::choose_kotlin_debugger(host, window, config).await?;
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
-415
Переглянути файл
@@ -1,415 +0,0 @@
//! Kotlin debugging: kotlin-lsp's debugger when the project's kotlin-lsp can serve it, else
//! kotlin-debug-adapter with a notice saying why.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::task::CancelHandle;
use ide_core::{CancellationToken, IdeEvent, IdeResult, SharedSink, TaskId};
use ide_dap::LaunchConfig;
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
pub(super) const KOTLIN_IMPORT_WAIT: Duration = Duration::from_secs(120);
/// A ready kotlin-lsp answers well within this; only a longer wait shows a task.
const TASK_DELAY: Duration = Duration::from_millis(500);
// Catalogued as written (`{detail}` replaced at runtime): the text catalog test counts these
// literals as the use of its entries.
const TASK_TITLE: &str = "Waiting for kotlin-lsp to import the project";
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
const NOT_CONFIGURED: &str = "kotlin-lsp is not the Kotlin language server of this project.";
const NOT_INSTALLED: &str = "kotlin-lsp is not installed.";
const NOT_STARTED: &str = "kotlin-lsp did not start: {detail}";
const STILL_IMPORTING: &str =
"kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.";
const IMPORT_FAILED: &str = "kotlin-lsp could not import the project.";
const FAILED: &str = "kotlin-lsp could not start its debugger: {detail}";
/// `config` for kotlin-lsp's debug server on `port`; the rest of the launch stays as it is.
pub(super) fn use_kotlin_lsp(mut config: LaunchConfig, port: u16) -> IdeResult<LaunchConfig> {
let mut extra = extra_object(&config)?;
extra.insert("debugServer".into(), port.into());
config.adapter = ide_dap::kotlin_lsp::ADAPTER.into();
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
Ok(config)
}
/// The dedupe key of `reason` and the notice body (English, translated by the UI's catalog).
pub(super) fn fallback_notice(reason: &KotlinDebugUnavailable) -> (&'static str, String) {
match reason {
KotlinDebugUnavailable::NotConfigured => ("notConfigured", NOT_CONFIGURED.into()),
KotlinDebugUnavailable::NotInstalled => ("notInstalled", NOT_INSTALLED.into()),
KotlinDebugUnavailable::NotStarted(detail) => ("notStarted", NOT_STARTED.replace("{detail}", detail)),
KotlinDebugUnavailable::StillImporting => ("stillImporting", STILL_IMPORTING.into()),
KotlinDebugUnavailable::ImportFailed => ("importFailed", IMPORT_FAILED.into()),
KotlinDebugUnavailable::Failed(detail) => ("failed", FAILED.replace("{detail}", detail)),
}
}
/// A kotlin-debug-adapter launch becomes a kotlin-lsp one when the project's kotlin-lsp has
/// imported the module and started its debug server; otherwise it stays on
/// kotlin-debug-adapter and the window is told why (once per reason).
pub(super) async fn choose_kotlin_debugger(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<LaunchConfig> {
if config.adapter == ide_dap::kotlin_lsp::ADAPTER {
require_debug_server(&config)?;
return Ok(config);
}
if config.adapter != ide_dap::kotlin::ADAPTER {
return Ok(config);
}
let Ok(project) = host.project(window) else {
tracing::info!(
"debugging Kotlin with kotlin-debug-adapter: {}",
fallback_notice(&KotlinDebugUnavailable::NotConfigured).1
);
return Ok(config);
};
let path = debug_path(&config).unwrap_or_else(|| project.root.clone());
let lsp = project.lsp.clone();
let chosen = wait_for_debug_server(&project.sink, |cancel| async move {
lsp.kotlin_debug_server(&path, KOTLIN_IMPORT_WAIT, &cancel).await
})
.await;
match chosen {
Ok(server) => use_kotlin_lsp(config, server.port),
Err(reason) => {
report_fallback(&project.kotlin_debug_notices, &project.sink, &reason);
Ok(config)
}
}
}
/// kotlin-lsp is only reached through the port its language server opened; without one
/// ide-dap would try to spawn a `kotlin-lsp` adapter that does not exist.
fn require_debug_server(config: &LaunchConfig) -> IdeResult<()> {
match extra_object(config)?.get("debugServer") {
None | Some(serde_json::Value::Null) => Err(ide_core::IdeError::invalid(
"A kotlin-lsp debug configuration needs \"debugServer\": the port of kotlin-lsp's debug server. \
Use kotlin-debug-adapter to let the IDE start it.",
)),
Some(_) => Ok(()),
}
}
fn extra_object(config: &LaunchConfig) -> IdeResult<serde_json::Map<String, serde_json::Value>> {
match config.extra_json.as_deref().map(serde_json::from_str).transpose() {
Ok(None) => Ok(serde_json::Map::new()),
Ok(Some(serde_json::Value::Object(extra))) => Ok(extra),
Ok(Some(other)) => Err(ide_core::IdeError::invalid(format!("launch arguments are not an object: {other}"))),
Err(e) => Err(ide_core::IdeError::invalid(e.to_string())),
}
}
/// The module to debug: the launch's `projectRoot`, else its working directory.
fn debug_path(config: &LaunchConfig) -> Option<PathBuf> {
let extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok());
extra
.as_ref()
.and_then(|e| e.get("projectRoot")?.as_str())
.filter(|root| !root.is_empty())
.map(PathBuf::from)
.or_else(|| config.cwd.as_deref().map(PathBuf::from))
}
/// Runs `debug_server` with a token the user can cancel; a wait longer than [`TASK_DELAY`]
/// shows as a cancellable task, which ends however the wait does (also when dropped).
async fn wait_for_debug_server<F, Fut>(
sink: &SharedSink,
debug_server: F,
) -> Result<KotlinDebugServer, KotlinDebugUnavailable>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = Result<KotlinDebugServer, KotlinDebugUnavailable>>,
{
let cancel = CancellationToken::new();
let id = TaskId::next();
let handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let wait = debug_server(cancel);
tokio::pin!(wait);
if let Ok(answer) = tokio::time::timeout(TASK_DELAY, &mut wait).await {
return answer;
}
let _task = WaitTask::start(sink.clone(), id, handle);
wait.await
}
/// The visible import wait; dropping it ends the task.
struct WaitTask {
sink: SharedSink,
id: TaskId,
_cancel: CancelHandle,
}
impl WaitTask {
fn start(sink: SharedSink, id: TaskId, cancel: CancelHandle) -> Self {
let task = Self { sink, id, _cancel: cancel };
task.emit(false);
task
}
fn emit(&self, done: bool) {
self.sink.emit(IdeEvent::Task {
id: self.id,
title: TASK_TITLE.into(),
message: None,
fraction: None,
done,
cancellable: !done,
});
}
}
impl Drop for WaitTask {
fn drop(&mut self) {
self.emit(true);
}
}
/// Logs every fallback; notifies the window once per reason.
fn report_fallback(notices: &Mutex<HashSet<&'static str>>, sink: &SharedSink, reason: &KotlinDebugUnavailable) {
let (key, body) = fallback_notice(reason);
tracing::info!("debugging Kotlin with kotlin-debug-adapter: {body}");
if notices.lock().insert(key) {
sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, title: NOTICE_TITLE.into(), body: Some(body) });
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Arc;
fn launch(adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some("/cwd".into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn extra(config: &LaunchConfig) -> serde_json::Value {
serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap()
}
fn reasons() -> Vec<KotlinDebugUnavailable> {
vec![
KotlinDebugUnavailable::NotConfigured,
KotlinDebugUnavailable::NotInstalled,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::ImportFailed,
KotlinDebugUnavailable::Failed("boom".into()),
]
}
fn recording_sink() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let recorded = events.clone();
(Arc::new(move |event| recorded.lock().push(event)), events)
}
/// `(done, cancellable)` of each task event, with the task id.
fn tasks(events: &Mutex<Vec<IdeEvent>>) -> Vec<(ide_core::TaskId, String, bool, bool)> {
events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { id, title, done, cancellable, .. } => Some((*id, title.clone(), *done, *cancellable)),
_ => None,
})
.collect()
}
fn server() -> KotlinDebugServer {
KotlinDebugServer { port: 4711, root: PathBuf::from("/p") }
}
#[test]
fn kotlin_lsp_takes_the_debug_server_port() {
let config = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"mainClass": "a.AppKt", "projectRoot": "/p"}));
let c = use_kotlin_lsp(config, 4711).unwrap();
assert_eq!(c.adapter, ide_dap::kotlin_lsp::ADAPTER);
let args = extra(&c);
assert_eq!(args["debugServer"], 4711);
assert_eq!(args["mainClass"], "a.AppKt");
assert_eq!(args["projectRoot"], "/p");
assert!(args.get("__adapterJavaHome").is_none());
assert_eq!(
(c.name.as_str(), c.request.as_str(), c.cwd.as_deref()),
("AppKt", "launch", Some(Path::new("/cwd")))
);
let mut bare = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert_eq!(extra(&use_kotlin_lsp(bare, 4711).unwrap())["debugServer"], 4711);
}
#[test]
fn every_fallback_reason_has_a_catalogued_text() {
let catalogs: [(&str, serde_json::Value); 2] = [
("en", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/en.json")).unwrap()),
("zh", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/zh.json")).unwrap()),
];
let templates = [NOT_CONFIGURED, NOT_INSTALLED, NOT_STARTED, STILL_IMPORTING, IMPORT_FAILED, FAILED];
for reason in reasons() {
let (_, body) = fallback_notice(&reason);
let detail = match &reason {
KotlinDebugUnavailable::NotStarted(d) | KotlinDebugUnavailable::Failed(d) => d.as_str(),
_ => "",
};
assert!(
templates.iter().any(|t| t.replace("{detail}", detail) == body),
"{reason:?}: {body:?} is not built from a catalogued template"
);
}
for text in templates.iter().chain([&NOTICE_TITLE, &TASK_TITLE]) {
for (locale, catalog) in &catalogs {
let translated = catalog[*text].as_str().unwrap_or_else(|| panic!("{locale}.json lacks {text:?}"));
if *locale == "zh" {
assert_ne!(translated, *text, "zh.json leaves {text:?} untranslated");
}
}
}
}
#[test]
fn fallback_reasons_have_distinct_keys() {
let keys: HashSet<_> = reasons().iter().map(|r| fallback_notice(r).0).collect();
assert_eq!(keys.len(), reasons().len());
// The detail does not make another reason.
assert_eq!(
fallback_notice(&KotlinDebugUnavailable::Failed("a".into())).0,
fallback_notice(&KotlinDebugUnavailable::Failed("b".into())).0
);
}
#[test]
fn a_kotlin_lsp_config_without_a_debug_server_is_refused() {
let refused = require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}))).unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
let mut bare = launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert!(require_debug_server(&bare).is_err());
assert!(
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": null})))
.is_err()
);
for port in [serde_json::json!(4711), serde_json::json!("10.0.0.5:4711")] {
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": port})))
.unwrap();
}
}
#[test]
fn the_module_is_the_project_root_else_the_working_directory() {
let with_root = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"projectRoot": "/p/app"}));
assert_eq!(debug_path(&with_root), Some(PathBuf::from("/p/app")));
assert_eq!(debug_path(&launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}))), Some(PathBuf::from("/cwd")));
let mut neither = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
neither.cwd = None;
assert_eq!(debug_path(&neither), None);
}
#[tokio::test(start_paused = true)]
async fn a_quick_answer_shows_no_task() {
let (sink, events) = recording_sink();
assert_eq!(wait_for_debug_server(&sink, |_| async { Ok(server()) }).await, Ok(server()));
assert!(events.lock().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_longer_wait_shows_a_task_that_ends_on_every_outcome() {
for outcome in [Ok(server()), Err(KotlinDebugUnavailable::ImportFailed)] {
let (sink, events) = recording_sink();
let answer = outcome.clone();
let result = wait_for_debug_server(&sink, |_| async move {
tokio::time::sleep(Duration::from_secs(5)).await;
answer
})
.await;
assert_eq!(result, outcome);
let tasks = tasks(&events);
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert_eq!(tasks[0].0, tasks[1].0);
assert_eq!(tasks[0].1, TASK_TITLE);
assert_eq!((tasks[0].2, tasks[0].3), (false, true));
assert_eq!((tasks[1].2, tasks[1].3), (true, false));
}
}
#[tokio::test(start_paused = true)]
async fn cancelling_the_task_stops_the_wait() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |cancel| async move {
cancel.cancelled().await;
Err(KotlinDebugUnavailable::StillImporting)
})
.await
});
tokio::time::sleep(Duration::from_secs(1)).await;
let id = tasks(&events)[0].0;
assert!(ide_core::task::cancel(id));
assert_eq!(wait.await.unwrap(), Err(KotlinDebugUnavailable::StillImporting));
assert_eq!(tasks(&events).last().map(|t| t.2), Some(true));
assert!(!ide_core::task::cancel(id), "an ended wait is no longer cancellable");
}
#[tokio::test(start_paused = true)]
async fn a_dropped_wait_ends_its_task() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |_| std::future::pending::<Result<KotlinDebugServer, _>>()).await
});
tokio::time::sleep(Duration::from_secs(1)).await;
wait.abort();
let _ = wait.await;
assert_eq!(tasks(&events).iter().map(|t| t.2).collect::<Vec<_>>(), [false, true]);
}
#[test]
fn the_notice_shows_once_per_reason() {
let (sink, events) = recording_sink();
let notices = Mutex::new(HashSet::new());
for reason in [
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::NotStarted("crashed".into()),
] {
report_fallback(&notices, &sink, &reason);
}
let notes: Vec<_> = events
.lock()
.iter()
.map(|e| match e {
IdeEvent::Notify { level, title, body } => (*level, title.clone(), body.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
notes,
[
(NotifyLevel::Info, NOTICE_TITLE.to_string(), Some(STILL_IMPORTING.to_string())),
(
NotifyLevel::Info,
NOTICE_TITLE.to_string(),
Some("kotlin-lsp did not start: needs JDK 21".to_string())
),
]
);
}
}
-123
Переглянути файл
@@ -1,123 +0,0 @@
//! Kotlin's debugger choice through a real `Host` and project: the fallback notice and the
//! refusal of a kotlin-lsp configuration without its debug server.
use super::*;
use crate::HostConfig;
use ide_core::IdeEvent;
use ide_core::event::NotifyLevel;
use ide_lsp::KotlinDebugUnavailable;
use ide_workspace::SettingsScope;
use parking_lot::Mutex;
use std::sync::Arc;
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
struct Fixture {
host: Host,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
_temp: tempfile::TempDir,
}
impl Fixture {
/// A trusted Gradle project opened with these global settings; kotlin-debug-adapter is a
/// missing executable, so a session fails right after the debugger is chosen.
async fn new(settings: &[(&str, serde_json::Value)]) -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("settings.gradle.kts"), "rootProject.name = \"app\"\n").unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = events.clone();
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(move |_| {
let events = captured.clone();
Arc::new(move |event| events.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let missing = base.join("missing").join("kotlin-debug-adapter");
host.global_settings
.set(SettingsScope::Global, "tools", serde_json::json!({"kotlin-debug-adapter": {"path": missing}}))
.unwrap();
for (key, value) in settings {
host.global_settings.set(SettingsScope::Global, key, value.clone()).unwrap();
}
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
Self { host, root, events, _temp: temp }
}
fn launch(&self, adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(self.root.clone().into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn kotlin_launch(&self) -> LaunchConfig {
let extra = serde_json::json!({"mainClass": "com.example.AppKt", "projectRoot": self.root});
self.launch(ide_dap::kotlin::ADAPTER, extra)
}
/// `(level, body)` of each fallback notice.
fn notices(&self) -> Vec<(NotifyLevel, Option<String>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { level, title, body } if title == NOTICE_TITLE => Some((*level, body.clone())),
_ => None,
})
.collect()
}
}
/// Two Debugs, each failing only at kotlin-debug-adapter's missing executable.
async fn debug_twice(f: &Fixture) {
for _ in 0..2 {
let error = debug_start(&f.host, "main", f.kotlin_launch()).await.unwrap_err();
assert!(!error.message.contains("Restricted Mode"), "{}", error.message);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kotlin_lsp_turned_off_gives_one_notice_over_two_debugs() {
let f = Fixture::new(&[("languageServers", serde_json::json!({"kotlin-lsp": {"enabled": false}}))]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotConfigured);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_missing_kotlin_lsp_gives_one_notice_over_two_debugs() {
let missing = serde_json::json!({"kotlin-lsp": {"command": "/nonexistent/kotlin-lsp"}});
let f = Fixture::new(&[("languageServers", missing)]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotInstalled);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_kotlin_lsp_config_without_its_debug_server_is_refused() {
let f = Fixture::new(&[]).await;
let config = f.launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"mainClass": "com.example.AppKt"}));
let refused = debug_start(&f.host, "main", config).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
assert!(f.notices().is_empty());
}
-30
Переглянути файл
@@ -169,25 +169,6 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -216,17 +197,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+4 -31
Переглянути файл
@@ -5,7 +5,7 @@ use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range};
use ide_language::{FoldRange, SyntaxHighlights};
use ide_text::LineEnding;
use ide_workspace::{DocumentEncoding, DocumentInfo, HistoryEntry};
use ide_workspace::{DocumentInfo, HistoryEntry};
use std::path::PathBuf;
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
@@ -19,17 +19,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
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?;
blocking(move || {
project.documents.open_virtual(
&vd.uri,
vd.language_id,
&vd.text,
&vd.encoding,
vd.has_bom,
vd.can_reload_encoding,
)
})
.await
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
@@ -39,34 +29,18 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
blocking(move || Ok(project.documents.ui_mode_of(&path))).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding and BOM state.
/// Virtual documents re-read their origin (archive, language server, Git blob)
/// instead of a disk file; converting them would write to it, so it stays refused.
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
pub async fn doc_set_encoding(
host: &Host,
window: &str,
doc: DocId,
encoding: String,
reload: bool,
) -> IdeResult<DocumentEncoding> {
) -> IdeResult<String> {
let project = host.project(window)?;
if reload && let Some(uri) = project.documents.virtual_uri(doc)? {
let vd = if uri.starts_with("git-revision:") {
crate::ops::vcs::revision_virtual_document(host, window, &uri, Some(&encoding)).await?
} else {
project.lsp.reload_virtual(&uri, &encoding).await?
};
return host.blocking(move || project.documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom)).await;
}
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
}
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
pub async fn doc_set_bom(host: &Host, window: &str, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
let project = host.project(window)?;
host.blocking(move || project.documents.set_bom(doc, has_bom)).await
}
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
host.project(window)?.documents.set_line_ending(doc, line_ending)
}
@@ -201,7 +175,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_set_encoding", |c| async move {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
});
d.add("doc_set_bom", |c| async move { doc_set_bom(&c.host, &c.window, c.arg("doc")?, c.arg("hasBom")?).await });
d.add("doc_set_line_ending", |c| async move {
blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await
});
+2 -5
Переглянути файл
@@ -176,11 +176,8 @@ async fn binary_and_virtual_documents_are_skipped() {
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc = f
.project
.documents
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
.unwrap();
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
+2 -45
Переглянути файл
@@ -1,8 +1,8 @@
use super::Channel;
use crate::Host;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_core::{IdeResult, RequestId};
use ide_vfs::FileEntry;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
host.blocking(move || {
@@ -165,46 +165,3 @@ pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<St
})
.await
}
/// The file `window` may hand to the OS's default application: `path` itself, resolved, when it is a
/// file inside the window's project (its folder or a content root). The window must be trusted and
/// open locally; a remote window's files live on its own machine and are not launched here.
pub fn system_open_target(host: &Host, window: &str, path: PathBuf) -> IdeResult<PathBuf> {
host.require_trust(window)?;
let project = host.project(window)?;
let real =
dunce::canonicalize(&path).map_err(|_| IdeError::not_found(format!("{}: no such file", path.display())))?;
if !real.is_file() {
return Err(IdeError::invalid(format!("{}: not a file", path.display())));
}
let roots: Vec<PathBuf> = std::iter::once(project.root.clone())
.chain(project.content_roots())
.filter_map(|root| dunce::canonicalize(root).ok())
.collect();
if !inside_any_root(&real, &roots) {
return Err(IdeError::invalid(format!("{}: outside the project", path.display())));
}
Ok(real)
}
/// Whether `path` lies inside one of `roots`, comparing whole path components (`/a/bc` is not inside `/a/b`).
pub(crate) fn inside_any_root(path: &Path, roots: &[PathBuf]) -> bool {
roots.iter().any(|root| path.starts_with(root))
}
#[cfg(test)]
mod tests {
use super::inside_any_root;
use std::path::{Path, PathBuf};
#[test]
fn system_open_is_limited_to_paths_inside_a_project_root() {
let roots = vec![PathBuf::from("/work/app"), PathBuf::from("/work/shared")];
assert!(inside_any_root(Path::new("/work/app/docs/spec.pdf"), &roots));
assert!(inside_any_root(Path::new("/work/shared/a.docx"), &roots));
assert!(inside_any_root(Path::new("/work/app"), &roots));
assert!(!inside_any_root(Path::new("/work/app2/spec.pdf"), &roots));
assert!(!inside_any_root(Path::new("/home/me/.ssh/id_rsa"), &roots));
assert!(!inside_any_root(Path::new("/work/spec.pdf"), &[]));
}
}
+10 -46
Переглянути файл
@@ -48,7 +48,7 @@ async fn resolve_launch(
let build = policy != BuildPolicy::NoBuild;
let full = policy == BuildPolicy::Rebuild;
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
let (model, external, delegated, prefer_maven) = {
let (model, external, delegated) = {
let project = project.clone();
let settings = settings.clone();
let module = module.clone();
@@ -58,17 +58,15 @@ async fn resolve_launch(
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
let is_maven =
model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module));
let prefer_maven = settings.languages.java.maven.delegate_build;
let delegated = build
&& delegates_to_external_build(
is_maven,
policy,
non_java,
cache::has_record(&project.root, &project.build_cache_dir(), &module),
prefer_maven,
);
let external = non_java || delegated;
(model, external, delegated, prefer_maven)
(model, external, delegated)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))?
@@ -81,7 +79,6 @@ async fn resolve_launch(
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
let built = if delegated {
let policy = delegated_policy(policy, prefer_maven, info.error_count);
let outputs = project.java_launch_snapshots.lock().build(
&project.root,
&model,
@@ -172,17 +169,6 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// With Settings › Maven › Delegate IDE build/run actions to Maven, javac's verdict replaces
/// JDT's. Classes JDT wrote for sources with errors look up to date to Maven's incremental
/// compiler, so a launch JDT reports errors for recompiles everything.
fn delegated_policy(policy: BuildPolicy, prefer_maven: bool, jdt_errors: u32) -> BuildPolicy {
if prefer_maven && jdt_errors > 0 && matches!(policy, BuildPolicy::Automatic | BuildPolicy::Make) {
BuildPolicy::Rebuild
} else {
policy
}
}
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>, clean: bool) {
info.build = Some(if let Some(failure) = failure {
info.hints.push(failure);
@@ -255,15 +241,8 @@ fn apply_library_classpath(
/// Whether a launch should be built by the project's build system instead of jdtls.
/// A Rebuild has to clean the output; jdtls' full build only recompiles sources, so
/// Maven modules always delegate for it, even when every source is Java.
/// `prefer_maven` is Settings › Maven › Delegate IDE build/run actions to Maven.
fn delegates_to_external_build(
is_maven: bool,
policy: BuildPolicy,
non_java: bool,
has_record: bool,
prefer_maven: bool,
) -> bool {
is_maven && (prefer_maven || policy == BuildPolicy::Rebuild || non_java || has_record)
fn delegates_to_external_build(is_maven: bool, policy: BuildPolicy, non_java: bool, has_record: bool) -> bool {
is_maven && (policy == BuildPolicy::Rebuild || non_java || has_record)
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
@@ -342,29 +321,14 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn delegated_launches_with_jdt_errors_recompile_with_maven() {
assert_eq!(delegated_policy(BuildPolicy::Make, true, 1), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Automatic, true, 2), BuildPolicy::Rebuild);
assert_eq!(delegated_policy(BuildPolicy::Make, true, 0), BuildPolicy::Make);
assert_eq!(delegated_policy(BuildPolicy::Make, false, 1), BuildPolicy::Make);
assert_eq!(
delegated_policy(BuildPolicy::MakeAndContinueOnErrors, true, 1),
BuildPolicy::MakeAndContinueOnErrors
);
}
#[test]
fn rebuild_delegates_maven_builds_to_the_build_system() {
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, false, false, true));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, false, true));
assert!(!delegates_to_external_build(false, BuildPolicy::Automatic, false, false, true));
assert!(delegates_to_external_build(true, BuildPolicy::Rebuild, false, false));
assert!(delegates_to_external_build(true, BuildPolicy::Automatic, true, false));
assert!(delegates_to_external_build(true, BuildPolicy::Make, false, true));
assert!(!delegates_to_external_build(true, BuildPolicy::Automatic, false, false));
assert!(!delegates_to_external_build(true, BuildPolicy::Make, false, false));
assert!(!delegates_to_external_build(false, BuildPolicy::Rebuild, false, false));
}
#[test]
+21 -207
Переглянути файл
@@ -314,12 +314,10 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
if let Ok((path, version)) = project.documents.identity(doc)
&& let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
})
{
let (path, version) = project.documents.identity(doc)?;
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
}) {
return Ok(found.targets);
}
}
@@ -546,7 +544,7 @@ pub async fn lsp_goto_implementation(
/// 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, None).await;
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
@@ -832,7 +830,7 @@ pub async fn lsp_code_actions(
};
let plugin_actions: Vec<CodeAction> = plugin_actions
.into_iter()
.map(|(plugin, a)| plugin_code_action_in(&plugin, &plugin_edit_roots(&project), a))
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, 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}."))))
@@ -848,21 +846,10 @@ pub async fn lsp_code_actions(
prepare_actions(project, doc, version, actions).await
}
/// Where a plugin's code action may change or open files: the project root and its content
/// roots (a group's members, extra Project Structure folders), never git's own files.
fn plugin_edit_roots(project: &crate::Project) -> Vec<std::path::PathBuf> {
std::iter::once(project.root.clone()).chain(project.content_roots()).collect()
}
#[cfg(test)]
fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> CodeAction {
plugin_code_action_in(plugin, &[root.to_path_buf()], a)
}
fn plugin_code_action_in(plugin: &str, roots: &[std::path::PathBuf], a: ide_plugin::CodeAction) -> CodeAction {
// With `data` and nothing to apply, the plugin computes the edits when the action is picked.
let lazy = a.edits.is_empty() && a.operations.is_empty() && a.data.is_some();
let (files, open, disabled_reason) = match plugin_file_edits(roots, &a) {
let (files, open, disabled_reason) = match plugin_file_edits(root, &a) {
Ok((files, open)) => (files, open, None),
Err(reason) => (Vec::new(), None, Some(reason)),
};
@@ -889,16 +876,17 @@ fn plugin_code_action_in(plugin: &str, roots: &[std::path::PathBuf], a: ide_plug
}
/// The action's file operations, then its text edits, as one workspace edit's files, and the
/// file to open. Every path stays inside `roots` (the project root first) and off git's own
/// files: one that does not disables the action.
/// file to open. An action with file operations or an `open` target stays inside the project
/// root as a whole: a path outside it disables the action.
fn plugin_file_edits(
roots: &[std::path::PathBuf],
root: &Path,
a: &ide_plugin::CodeAction,
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
use ide_core::FileEdit;
use ide_plugin::FileOperation;
let confined = |path: &Path| ide_plugin::resolve_new_in_roots(roots, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_roots(roots, &path.to_string_lossy());
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
@@ -925,14 +913,13 @@ fn plugin_file_edits(
}
}
for (path, edits) in &a.edits {
let path = writable(path)?;
let path = if confine { writable(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
}
// Opening a file hands its text to the plugin, so a link is judged by the file it leads to.
let open = a.open.as_deref().map(writable).transpose()?;
let open = a.open.as_deref().map(confined).transpose()?;
Ok((files, open))
}
@@ -960,9 +947,8 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
let plugin = r.plugin.clone();
let invocation = ide_plugin::Invocation { client: Some(window.to_owned()), ..Default::default() };
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending, invocation)).await?;
let project = host.project(window)?;
let roots = plugin_edit_roots(&project);
Ok(plugin_code_action_in(&r.plugin, &roots, ide_plugin::CodeAction { data: None, ..resolved }))
let root = host.project(window)?.root.clone();
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -2260,18 +2246,18 @@ mod tests {
};
for name in ["outside", "dangling"] {
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
assert!(plugin_file_edits(std::slice::from_ref(&root), &action(vec![create], Vec::new())).is_err());
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
assert!(plugin_file_edits(std::slice::from_ref(&root), &action(Vec::new(), edits)).is_err());
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
}
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
let (files, _) = plugin_file_edits(std::slice::from_ref(&root), &action(vec![create], Vec::new())).unwrap();
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
let operations = vec![
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
FileOperation::Delete { path: root.join("outside") },
];
let (files, _) = plugin_file_edits(std::slice::from_ref(&root), &action(operations, Vec::new())).unwrap();
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Rename { from, to }
if **from == root.join("outside") && **to == root.join("dangling")));
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
@@ -2355,178 +2341,6 @@ mod tests {
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
}
/// Advisory #4b: an action carrying only text edits (no file operation, no `open`) must still
/// be confined to the project root and kept off git's own files. A plugin needs no grant to
/// offer a quick fix, so this is decided here.
#[test]
fn a_plugin_edits_only_action_stays_in_the_project_and_off_git() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git/config"), "[core]\n").unwrap();
std::fs::write(root.join("a.txt"), "a").unwrap();
let root = dunce::canonicalize(&root).unwrap();
let edits_only = |path: std::path::PathBuf| ide_plugin::CodeAction {
title: "Add import".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: vec![(path, vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
operations: Vec::new(),
open: None,
data: None,
file_action: false,
};
// In the project root: allowed.
assert_eq!(plugin_code_action("acme.x", &root, edits_only(root.join("a.txt"))).disabled_reason, None);
// Outside the root: disabled (previously an edits-only action was not confined).
let outside = plugin_code_action("acme.x", &root, edits_only(tmp.path().join("evil.txt")));
assert!(
outside.disabled_reason.as_deref().is_some_and(|r| r.contains("outside the project root")),
"{outside:?}"
);
assert!(outside.edit.unwrap().files.is_empty());
// Into .git: disabled.
let git = plugin_code_action("acme.x", &root, edits_only(root.join(".git/config")));
assert!(git.disabled_reason.as_deref().is_some_and(|r| r.contains("git's own files")), "{git:?}");
}
/// A project window group's files live in its members, outside the container root: a quick
/// fix reaches them, and their `.git` stays out of reach.
#[test]
fn a_plugin_action_reaches_the_content_roots_but_not_their_git() {
use ide_plugin::FileOperation::Create;
let tmp = tempfile::tempdir().unwrap();
for dir in ["group/.openidea", "member/.git", "member/src", "elsewhere"] {
std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
}
let base = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(base.join("member/src/a.txt"), "a").unwrap();
std::fs::write(base.join("member/.git/config"), "[core]\n").unwrap();
let roots = [base.join("group"), base.join("member")];
let action =
|edits: Vec<std::path::PathBuf>, operations: Vec<ide_plugin::FileOperation>| ide_plugin::CodeAction {
title: "Fix".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: edits
.into_iter()
.map(|path| (path, vec![TextEdit { range: Range::default(), new_text: "x".into() }]))
.collect(),
operations,
open: None,
data: None,
file_action: false,
};
let offer = |a| plugin_code_action_in("acme.x", &roots, a);
assert_eq!(offer(action(vec![base.join("member/src/a.txt")], Vec::new())).disabled_reason, None);
let created = Create { path: base.join("member/src/b.txt"), text: String::new(), overwrite: false };
assert_eq!(offer(action(Vec::new(), vec![created])).disabled_reason, None);
for refused in [base.join("member/.git/config"), base.join("elsewhere/c.txt")] {
let offered = offer(action(vec![refused.clone()], Vec::new()));
assert!(offered.disabled_reason.is_some(), "{refused:?}: {offered:?}");
assert!(offered.edit.unwrap().files.is_empty());
}
let hook = Create { path: base.join("member/.git/hooks/pre-commit"), text: "x".into(), overwrite: false };
assert!(offer(action(Vec::new(), vec![hook])).disabled_reason.is_some());
}
/// Advisory #4b: a quick fix's file operations and its `open` are held to the same rule as its
/// edits, under every name that reaches git's own files.
#[test]
fn a_plugin_action_cannot_change_or_open_git_files() {
use ide_plugin::FileOperation::{Create, Delete, Rename};
let tmp = tempfile::tempdir().unwrap();
for dir in ["proj/.git/hooks", "worktree"] {
std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
}
let root = dunce::canonicalize(tmp.path().join("proj")).unwrap();
let worktree = dunce::canonicalize(tmp.path().join("worktree")).unwrap();
std::fs::write(
root.join(".git/config"),
"[core]
",
)
.unwrap();
std::fs::write(root.join("a.txt"), "a").unwrap();
std::fs::write(
worktree.join(".git"),
"gitdir: elsewhere
",
)
.unwrap();
std::fs::write(tmp.path().join("profile.txt"), "outside").unwrap();
let action =
|edits: Vec<std::path::PathBuf>, operations: Vec<ide_plugin::FileOperation>| ide_plugin::CodeAction {
title: "Add import".into(),
kind: Some("quickfix".into()),
preferred: false,
edits: edits
.into_iter()
.map(|path| (path, vec![TextEdit { range: Range::default(), new_text: "x".into() }]))
.collect(),
operations,
open: None,
data: None,
file_action: false,
};
let refused = |root: &Path, action: ide_plugin::CodeAction, why: &str| {
let offered = plugin_code_action("acme.x", root, action);
assert!(offered.disabled_reason.as_deref().is_some_and(|r| r.contains(why)), "{offered:?}");
assert!(offered.edit.unwrap().files.is_empty());
};
for git in [root.join(".git/config"), root.join(".GIT/config"), ".git/hooks/pre-commit".into()] {
refused(&root, action(vec![git], Vec::new()), "git's own files");
}
for operation in [
Create {
path: root.join(".git/hooks/pre-commit"),
text: "#!/bin/sh
"
.into(),
overwrite: false,
},
Rename { from: root.join("a.txt"), to: root.join(".git/config") },
Rename { from: root.join(".git/config"), to: root.join("b.txt") },
Delete { path: root.join(".git/config") },
] {
refused(&root, action(Vec::new(), vec![operation]), "git's own files");
}
// Opening a file hands its text to the plugin's providers.
let opens =
|path: std::path::PathBuf| ide_plugin::CodeAction { open: Some(path), ..action(Vec::new(), Vec::new()) };
refused(&root, opens(root.join(".git/config")), "git's own files");
// An edit lands in the file a link leads to, and `open` shows that file: both are judged by it.
#[cfg(unix)]
{
std::os::unix::fs::symlink(tmp.path().join("profile.txt"), root.join("link.txt")).unwrap();
std::os::unix::fs::symlink(root.join(".git/config"), root.join("alias")).unwrap();
refused(&root, action(vec![root.join("link.txt")], Vec::new()), "outside the project root");
refused(&root, action(vec![root.join("alias")], Vec::new()), "git's own files");
refused(&root, opens(root.join("link.txt")), "outside the project root");
refused(&root, opens(root.join("alias")), "git's own files");
}
// A linked worktree's `.git` file, under the short name Windows opens it by as well.
if cfg!(windows) && worktree.join("GIT~1").exists() {
for operation in [
Delete { path: worktree.join("GIT~1") },
Create {
path: worktree.join("GIT~1"),
text: "gitdir: other
"
.into(),
overwrite: true,
},
] {
refused(&worktree, action(Vec::new(), vec![operation]), "git's own files");
}
refused(&worktree, action(vec![worktree.join("GIT~1")], Vec::new()), "git's own files");
refused(&worktree, opens(worktree.join("GIT~1")), "git's own files");
} else {
eprintln!("short name not tried: this volume keeps none");
}
}
#[test]
fn every_completion_kind_the_plugin_api_documents_is_understood() {
let wit = include_str!("../../../../wit/providers.wit");
-8
Переглянути файл
@@ -19,13 +19,6 @@ pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot>
host.project(window)?.notebooks.get(&id)
}
/// The language-server documents of the notebook's code cells (empty when no server syncs notebooks).
pub fn cell_docs(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_lsp::NotebookCellDoc>> {
let project = host.project(window)?;
let path = project.notebooks.path_of(&id)?;
Ok(project.lsp.notebook_cells(&path))
}
pub async fn edit(
host: &Host,
window: &str,
@@ -139,7 +132,6 @@ impl Drop for RequestGuard<'_> {
pub(crate) fn register(d: &mut Dispatcher) {
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_cell_docs", |c| async move { cell_docs(&c.host, &c.window, c.arg("id")?) });
d.add("notebook_edit", |c| async move {
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
});
+2 -10
Переглянути файл
@@ -231,16 +231,8 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// sources of every module in the build, so a repeated run does not rebuild (IDEA's
// up-to-date check) but a change in a dependency module does.
let modules: Vec<std::path::PathBuf> = model
.maven
.iter()
.flat_map(|m| m.projects.iter().map(|p| p.dir.as_str()))
.chain(model.gradle.iter().flat_map(|g| g.projects.iter().map(|p| p.dir.as_str())))
.map(std::path::PathBuf::from)
.collect();
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config, &modules) {
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
-18
Переглянути файл
@@ -161,23 +161,6 @@ pub async fn jdk_download(
Ok(sdk)
}
/// The Java version the jdtls this window launches needs; `None` while it isn't
/// installed or doesn't tell.
pub async fn jdtls_required_java(host: &Host, window: &str) -> IdeResult<Option<u32>> {
let settings = match host.project(window) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
let Some(mut cfg) =
languages(host, window).into_iter().flat_map(|l| l.language_servers).find(|s| s.name == "jdtls")
else {
return Ok(None);
};
ide_sdk::apply::server_launch_overrides(&mut cfg, &settings);
ide_sdk::apply::server_protocol_overrides(&mut cfg, &settings);
tokio::task::spawn_blocking(move || ide_sdk::java::jdtls_required_java(&cfg)).await.map_err(join_err)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
@@ -190,7 +173,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
});
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
d.add("jdtls_required_java", |c| async move { jdtls_required_java(&c.host, &c.window).await });
d.add("jdk_download", |c| async move {
jdk_download(
&c.host,
+1 -59
Переглянути файл
@@ -20,13 +20,10 @@ struct LogAccess {
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
/// the project's detection.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
encoding: Option<&str>,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
@@ -50,7 +47,7 @@ pub async fn revision_virtual_document(
.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, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
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 {
@@ -60,63 +57,8 @@ pub async fn revision_virtual_document(
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
encoding: charset.name().to_ascii_lowercase(),
has_bom,
can_reload_encoding: true,
})
}
/// Revision text with the charset it was decoded with and whether a matching
/// BOM was stripped; `None` when the file is absent at the revision.
async fn revision_text(
host: &Host,
window: &str,
path: &Path,
rev: &str,
encoding: Option<&str>,
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
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 (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
blocking(move || {
fn with_repo<T>(
project: &Option<std::sync::Arc<Project>>,
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
path: &Path,
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
) -> IdeResult<T> {
match project {
Some(project) => project.with_log_repo_of(path, f),
None => f(external
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
}
}
let (text_encoding, charset) = match &encoding {
Some(label) => {
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
(ide_vcs::TextEncoding::from(charset), charset)
}
None => match &project {
Some(project) => {
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
(text_encoding, text_encoding.charset)
}
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
},
};
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
let Some(bytes) = bytes else { return Ok(None) };
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
let text =
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
Ok(Some((text, charset, has_bom)))
})
.await
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
+55 -275
Переглянути файл
@@ -71,12 +71,6 @@ pub trait PluginWorkspace: Send + Sync {
/// Roots of the open projects.
fn projects(&self) -> Vec<PathBuf>;
/// Roots of the workspace members attached to the project at `root` (a
/// project window group's folders, as the editor and LSP resolve them).
/// Empty for a plain project.
fn member_roots(&self, _root: &Path) -> Vec<PathBuf> {
Vec::new()
}
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// A window's unsaved text, with the normal project overlay as fallback.
@@ -111,9 +105,8 @@ pub trait PluginWorkspace: Send + Sync {
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
/// `version`, as one change: one undo step in the editor, or none for a
/// `background` caller (a plugin event rather than a command).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit], background: bool) -> Result<u64, HostError>;
/// `version`, as one change (one undo step in the editor).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError>;
/// Code from the project may run (git hooks and config on fetch).
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
@@ -181,7 +174,7 @@ impl SecretStore for FileSecretStore {
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 16 << 20;
const MAX_OUTPUT: usize = 4 << 20;
/// Most processes one plugin keeps running at once.
const MAX_PROCESSES: usize = 8;
/// Most output one stream of a handle holds until the plugin reads it.
@@ -489,7 +482,6 @@ type SettingSnapshots = BTreeMap<(Option<String>, String), BTreeMap<String, Stri
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
pub websockets: super::websocket::WebSocketHub,
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
/// `<plugins>/.data`: one directory per plugin id.
pub data_dir: PathBuf,
@@ -567,8 +559,7 @@ impl Shared {
ui,
registry,
locale: RwLock::new("en".into()),
sink: sink.clone(),
websockets: super::websocket::WebSocketHub::new(sink.clone()),
sink,
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
@@ -778,7 +769,6 @@ impl Shared {
self.kill_processes(plugin);
self.connections.clear(plugin);
self.websockets.disconnect_plugin(plugin);
self.setting_snapshots.lock().retain(|(_, p), _| p != plugin);
let channels: Vec<_> = {
let mut output = self.output.lock();
@@ -900,13 +890,6 @@ impl Shared {
self.prompts_blocked.lock().remove(plugin);
}
/// A window other than `client` where `plugin` waits for the user's answer (a prompt or a
/// Commit request). The plugin runs one call at a time, so anything else asked of it waits
/// behind that answer.
pub fn waiting_elsewhere(&self, plugin: &str, client: &str) -> Option<String> {
self.prompts.lock().values().find(|p| p.plugin == plugin && p.client != client).map(|p| p.client.clone())
}
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let mut prompts = self.prompts.lock();
@@ -1274,8 +1257,6 @@ pub(super) fn commit_draft(
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
// Both callers have checked that the window's project is trusted.
r.set_execution_allowed(true);
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
@@ -2038,10 +2019,6 @@ impl HostApi for PluginApi {
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
}
fn member_roots(&self, root: &Path) -> Vec<PathBuf> {
self.shared.workspace().map(|w| w.member_roots(root)).unwrap_or_default()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
self.shared.workspace()?.buffer_text(path)
}
@@ -2052,14 +2029,13 @@ impl HostApi for PluginApi {
fn apply_edits(
&self,
client: Option<&str>,
_client: Option<&str>,
path: &Path,
version: u64,
edits: Vec<TextEdit>,
) -> Result<u64, HostError> {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
// Edits from a window's command are undoable; `editor.apply-edits-at` from a background event isn't.
workspace.apply_edits(path, version, &edits, client.is_none())
workspace.apply_edits(path, version, &edits)
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
@@ -2107,7 +2083,7 @@ impl HostApi for PluginApi {
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
let Some(repo) = self.repo(root)? else { return Ok(None) };
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) };
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
@@ -2133,7 +2109,7 @@ impl HostApi for PluginApi {
if !workspace.is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
}
let Some(repo) = self.repo(root)? else {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else {
return Err(HostError::NotFound("the project is not in a git repository".into()));
};
match repo.fetch(cancel, &|_| {}) {
@@ -2183,29 +2159,23 @@ impl HostApi for PluginApi {
out.sort();
return Ok(out);
}
// A project window group's members are project scope too (their files
// are in the group's index, and a group root holds none of them).
let mut roots = vec![root.to_path_buf()];
roots.extend(self.member_roots(root));
let mut out = Vec::new();
'walk: for root in &roots {
for (n, entry) in ignore::WalkBuilder::new(root)
.hidden(false)
.filter_entry(|e| e.file_name() != ".git")
.build()
.flatten()
.enumerate()
{
if n >= MAX_WALK || out.len() >= limit {
break 'walk;
}
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(to_ipc(entry.path()));
}
for (n, entry) in ignore::WalkBuilder::new(root)
.hidden(false)
.filter_entry(|e| e.file_name() != ".git")
.build()
.flatten()
.enumerate()
{
if n >= MAX_WALK || out.len() >= limit {
break;
}
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(to_ipc(entry.path()));
}
}
out.sort();
@@ -2280,49 +2250,6 @@ impl HostApi for PluginApi {
})
}
fn websocket_connect(
&self,
root: &Path,
request: ide_plugin::WebSocketRequest,
timeout: Duration,
) -> Result<u64, HostError> {
let secrets = self.shared.secrets.read().clone();
let plugin = self.id().to_owned();
let headers = request
.headers
.into_iter()
.map(|(name, value)| {
let value = fill_secrets(&value, |name| secrets.get(&plugin, name))?;
Ok((name, value))
})
.collect::<Result<Vec<_>, HostError>>()?;
net_runtime()
.block_on(self.shared.websockets.connect(
&plugin,
&to_ipc(root),
&request.url,
&headers,
&request.subprotocol,
timeout,
))
.map_err(|error| match error {
super::websocket::ConnectError::Invalid(message) => HostError::Invalid(message),
super::websocket::ConnectError::Failed(message) => HostError::Failed(message),
})
}
fn websocket_send(&self, connection: u64, bytes: Vec<u8>) -> Result<(), HostError> {
self.shared.websockets.send(self.id(), connection, bytes).map_err(HostError::Failed)
}
fn websocket_drain(&self, connection: u64, limit: usize) -> Result<ide_plugin::WebSocketBatch, HostError> {
self.shared.websockets.drain(self.id(), connection, limit).map_err(HostError::Invalid)
}
fn websocket_close(&self, connection: u64) {
let _ = self.shared.websockets.disconnect(self.id(), connection);
}
/// The `net:ask` answer: a host the user allowed with "always" is
/// remembered in the ledger (no dialog again, background included); a new
/// host is asked about in `client`'s window when there is one. Dismissal
@@ -2491,14 +2418,9 @@ impl HostApi for PluginApi {
let read = |pipe: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut out = Vec::new();
let mut overflow = false;
if let Some(p) = pipe {
let mut p = p;
let _ = p.by_ref().take((MAX_OUTPUT + 1) as u64).read_to_end(&mut out);
if out.len() > MAX_OUTPUT {
overflow = true;
out.truncate(MAX_OUTPUT);
}
let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out);
let _ = std::io::copy(&mut p, &mut std::io::sink());
}
let mut text = String::from_utf8_lossy(&out).into_owned();
@@ -2507,7 +2429,7 @@ impl HostApi for PluginApi {
end -= 1;
}
text.truncate(end);
(text, overflow)
text
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
@@ -2527,18 +2449,11 @@ impl HostApi for PluginApi {
}
std::thread::sleep(Duration::from_millis(10));
};
let (stdout, stdout_overflow) = stdout.join().unwrap_or_default();
let (stderr, _) = stderr.join().unwrap_or_default();
if stdout_overflow {
tracing::warn!(
plugin = %self.id(),
program = %cmd.program,
limit_mib = MAX_OUTPUT >> 20,
"process stdout exceeded limit"
);
return Err(HostError::Failed(format!("{} stdout exceeded {} MiB limit", cmd.program, MAX_OUTPUT >> 20)));
}
Ok(ProcessOutput { status: status.code(), stdout, stderr })
Ok(ProcessOutput {
status: status.code(),
stdout: stdout.join().unwrap_or_default(),
stderr: stderr.join().unwrap_or_default(),
})
}
fn subscribe(&self, kind: EventKind) {
@@ -2977,26 +2892,11 @@ impl HostWorkspace {
.and_then(|w| host.project(w).ok())
.filter(|p| p.root == root)
.or_else(|| host.windows_for_root(root).first().and_then(|w| host.project(w).ok()))
// A project window group's member root: its files belong to the
// group's project, like everywhere else in the editor.
.or_else(|| {
host.projects()
.into_iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| m.as_path() == root))
})
}
fn projects(&self) -> Vec<Arc<crate::project::Project>> {
self.0.upgrade().map(|h| h.projects()).unwrap_or_default()
}
/// The open project whose workspace group contains `root` (the group root
/// itself matches `p.root == root` and needs no member lookup).
fn project_covering(&self, root: &Path) -> Option<Arc<crate::project::Project>> {
self.projects().into_iter().find(|p| {
p.root == root || crate::project::workspace_member_roots(&p.root).iter().any(|m| m.as_path() == root)
})
}
/// The project holding an open document at `path`, and its id.
fn open_document(&self, path: &Path) -> Option<(Arc<crate::project::Project>, ide_core::DocId)> {
self.projects().into_iter().find_map(|p| p.documents.by_path(path).map(|doc| (p, doc)))
@@ -3086,10 +2986,6 @@ impl PluginWorkspace for HostWorkspace {
) -> Result<ide_plugin::TextSearch, HostError> {
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
let project = self.project_for(client, root).ok_or_else(|| HostError::NotFound("project closed".into()))?;
// Matches must land in the project root or, for a project window group,
// in any of its members.
let mut roots = vec![root.to_path_buf()];
roots.extend(crate::project::workspace_member_roots(root));
if limit == 0 {
return Ok(ide_plugin::TextSearch::default());
}
@@ -3119,9 +3015,8 @@ impl PluginWorkspace for HostWorkspace {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
// Restrict project.read to the project root and its workspace
// members, including symlink targets.
let Ok(path) = ide_plugin::resolve_in_roots(&roots, &to_ipc(&file.path.0)) else {
// Restrict project.read to the project root, including symlink targets.
let Ok(path) = ide_plugin::resolve_in_root(root, &to_ipc(&file.path.0)) else {
continue;
};
let text = project.documents.text_of(&path).or_else(|| {
@@ -3187,19 +3082,12 @@ impl PluginWorkspace for HostWorkspace {
}
fn projects(&self) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = HostWorkspace::projects(self)
.iter()
.flat_map(|p| std::iter::once(p.root.clone()).chain(crate::project::workspace_member_roots(&p.root)))
.collect();
let mut roots: Vec<PathBuf> = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect();
roots.sort();
roots.dedup();
roots
}
fn member_roots(&self, root: &Path) -> Vec<PathBuf> {
crate::project::workspace_member_roots(root)
}
fn buffer_text(&self, path: &Path) -> Option<String> {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
@@ -3210,9 +3098,9 @@ impl PluginWorkspace for HostWorkspace {
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
// The index covering `root`: the project rooted there, or the workspace
// group project whose member it is (a member's files are in the group's).
HostWorkspace::project_covering(self, root)?.index.files_matching(root, matches, limit)
// The index of the project rooted at `root` (a workspace member's files are in its own).
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
project.index.files_matching(root, matches, limit)
}
fn symbols(
@@ -3223,7 +3111,9 @@ impl PluginWorkspace for HostWorkspace {
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
let project = HostWorkspace::project_covering(self, root)
let project = HostWorkspace::projects(self)
.into_iter()
.find(|p| p.root == root)
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
let stopped = || cancel.is_cancelled() || Instant::now() >= deadline;
let interrupted = || {
@@ -3233,13 +3123,15 @@ impl PluginWorkspace for HostWorkspace {
HostError::Failed("symbol search timed out".into())
}
};
// The symbols of the requested root and, for a project window group, of
// its members (a member's symbols are in the group's index).
let members = crate::project::workspace_member_roots(root);
let in_scope = |path: &Path| path.starts_with(root) || members.iter().any(|m| path.starts_with(m));
let symbols = project
.index
.find_symbols_until(query, ide_index::SymbolFilter::All, limit.min(1000), &in_scope, &stopped)
.find_symbols_until(
query,
ide_index::SymbolFilter::All,
limit.min(1000),
&|path| path.starts_with(root),
&stopped,
)
.ok_or_else(interrupted)?;
let mut out = Vec::new();
for s in symbols {
@@ -3268,16 +3160,11 @@ impl PluginWorkspace for HostWorkspace {
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit], background: bool) -> Result<u64, HostError> {
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError> {
let (project, doc) =
self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?;
let version = u32::try_from(version).map_err(|_| HostError::Stale)?;
let applied = if background {
project.documents.apply_background_edits_at(doc, version, edits)
} else {
project.documents.apply_edits_at(doc, version, edits)
};
match applied {
match project.documents.apply_edits_at(doc, version, edits) {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)),
@@ -3286,8 +3173,7 @@ impl PluginWorkspace for HostWorkspace {
}
fn trusted(&self, root: &Path) -> bool {
// A workspace member is as trusted as the group's project it was opened with.
HostWorkspace::project_covering(self, root).is_some_and(|p| p.is_trusted())
HostWorkspace::projects(self).iter().any(|p| p.root == root && p.is_trusted())
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
@@ -3312,12 +3198,6 @@ impl PluginWorkspace for HostWorkspace {
.iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
// A project window group's member files report through the group's project.
.or_else(|| {
projects
.iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
@@ -3332,25 +3212,15 @@ impl PluginWorkspace for HostWorkspace {
.into_iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.or_else(|| {
HostWorkspace::projects(self)
.into_iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.and_then(|p| p.vcs_encoding(path, None).ok())
.unwrap_or_else(|| encoding_rs::UTF_8.into())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
// A member root reads the settings of the group's project, like the
// group's own root does.
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
Some(p) => p.settings.effective(),
None => match root.map(|r| HostWorkspace::project_covering(self, r)) {
Some(Some(p)) => p.settings.effective(),
_ => host.global_settings.effective(),
},
None => host.global_settings.effective(),
};
settings.plugins.get(plugin).cloned().unwrap_or_default()
}
@@ -3607,7 +3477,7 @@ mod tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit], _: bool) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
@@ -3656,55 +3526,6 @@ mod tests {
);
}
/// A workspace whose container root has no files: the member holds them.
struct Members(PathBuf, Vec<PathBuf>);
impl PluginWorkspace for Members {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone()]
}
fn member_roots(&self, root: &Path) -> Vec<PathBuf> {
if root == self.0 { self.1.clone() } else { Vec::new() }
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit], _: bool) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
}
#[test]
fn find_files_cover_workspace_members() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let (root, member) = (base.join("workspace"), base.join("member"));
std::fs::create_dir_all(root.join(".openidea")).unwrap();
std::fs::create_dir_all(member.join("src")).unwrap();
std::fs::write(root.join(".openidea/workspace.json"), "{}").unwrap();
std::fs::write(member.join("src/UserMapper.xml"), "<mapper/>").unwrap();
let api = api(&root, Arc::default());
*api.shared.workspace.write() = Some(Arc::new(Members(root.clone(), vec![member.clone()])));
// The container holds no sources: the member's files are project scope.
assert_eq!(api.find_files(&root, "**/*Mapper.xml", 10).unwrap(), [to_ipc(&member.join("src/UserMapper.xml"))]);
// A member root lists its own files, like any project root.
assert_eq!(api.find_files(&member, "src/*.xml", 10).unwrap(), [to_ipc(&member.join("src/UserMapper.xml"))]);
// And a path outside the group is never listed.
std::fs::write(base.join("OutsideMapper.xml"), "<mapper/>").unwrap();
assert!(api.find_files(&root, "**/*Mapper.xml", 10).unwrap().len() == 1);
}
#[test]
fn process_environment_only_adds_variables() {
let tmp = tempfile::tempdir().unwrap();
@@ -3872,11 +3693,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
// stdout within the limit comes back whole; stderr beyond it is truncated (stdout beyond it fails).
let cmd = shell(&format!("head -c 5242880 /dev/zero; head -c {} /dev/zero >&2", MAX_OUTPUT + (1 << 20)));
let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2");
let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (5 << 20, MAX_OUTPUT));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT));
let id = api.start_process(cmd, None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap();
assert_eq!(out.status, Some(0));
@@ -4011,16 +3831,6 @@ fn valid_range(document: &ide_text::Document, range: ide_core::Range) -> bool {
}
impl PluginApi {
/// The repository of `root`, which may execute only when `root` is a trusted project.
/// Status needs it there: a restricted repository is read without its config, so it knows
/// no upstream. The other reads use [`Self::read_repo`] and never execute.
fn repo(&self, root: &Path) -> Result<Option<ide_vcs::Repo>, HostError> {
let repo = ide_vcs::Repo::discover(root).map_err(failed)?;
if let Some(repo) = &repo {
repo.set_execution_allowed(self.shared.workspace().is_some_and(|w| w.trusted(root)));
}
Ok(repo)
}
fn read_repo(&self, root: &Path) -> Result<Option<ide_vcs::Repo>, HostError> {
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
@@ -4132,7 +3942,7 @@ mod workbench_tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
self.text.clone().map(|s| (1, s))
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit], _: bool) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
@@ -4285,36 +4095,6 @@ mod workbench_tests {
std::fs::write(&path, "x".repeat(5 << 20)).unwrap();
assert!(api.vcs_diff(&root, None, None, &cancel).is_err());
}
/// Security audit lead 2: status has no trust check to stop it, so in Restricted Mode the
/// repository it opens must not run what its config names.
#[test]
fn vcs_status_runs_no_repository_program_in_restricted_mode() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
git(&root, &["init", "-q"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("a.txt"), "base\n").unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-qm", "base"]);
let marker = dir.path().join("FILTER-RAN");
let clean = format!("echo x > '{}'; cat", marker.to_str().unwrap().replace('\\', "/"));
git(&root, &["config", "filter.marker.clean", &clean]);
std::fs::write(root.join(".gitattributes"), "*.txt filter=marker\n").unwrap();
// Same length as the committed text: only reading the file tells them apart.
std::fs::write(root.join("a.txt"), "BASE\n").unwrap();
let restricted = api(&root, false, None);
let status = restricted.vcs_status(&root);
assert!(!marker.exists(), "status ran the repository's clean filter in Restricted Mode");
assert_eq!(status.unwrap().unwrap().changed_files, 2);
// Control: the same call runs the fixture's filter in a trusted project.
api(&root, true, None).vcs_status(&root).unwrap();
assert!(marker.exists(), "control: a trusted status did not run the clean filter, fixture is not live");
}
#[cfg(unix)]
#[test]
fn vcs_reads_never_run_repository_configured_helpers() {
+1 -1
Переглянути файл
@@ -262,7 +262,7 @@ mod tests {
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit], _: bool) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
unreachable!()
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
+6 -29
Переглянути файл
@@ -1,7 +1,6 @@
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
use super::{LoadedPlugin, PluginHost};
use globset::{GlobBuilder, GlobMatcher};
use ide_extension::Capability;
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
use std::{
collections::{BTreeSet, HashMap},
@@ -22,9 +21,9 @@ pub(super) struct PathFilter {
impl PathFilter {
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
if !matches!(kind, "file-opened" | "file-saved" | "files-changed" | "document-changed") {
if !matches!(kind, "file-opened" | "file-saved" | "files-changed") {
return Err(HostError::Invalid(
"subscribe-paths supports file-opened, file-saved, files-changed and document-changed".into(),
"subscribe-paths supports file-opened, file-saved and files-changed".into(),
));
}
if globs.len() > 64 {
@@ -96,15 +95,13 @@ impl PluginHost {
events: &[WorkbenchEvent],
subs: &Subscriptions,
) -> Vec<WorkbenchEvent> {
let filters = subs.paths.get(plugin.id());
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
let workspace = shared.workspace();
events
.iter()
.filter_map(|event| {
if let WorkbenchEvent::FilesChanged(batch) = event {
let Some(filter) = filters.and_then(|filters| filters.get(event.kind().name())) else {
return Some(event.clone());
};
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
let root = workspace.as_ref()?.project_of(client?)?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
@@ -129,9 +126,9 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
let Some(filter) = filters.get(event.kind().name()) else { return Some(event.clone()) };
if !path.is_absolute() {
return None;
}
@@ -150,26 +147,7 @@ impl PluginHost {
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
}
if let WorkbenchEvent::DocumentChanged(change) = event {
// Typed text: only for plugins that may read editors, and only for paths they named.
let filter = filters.and_then(|filters| filters.get(event.kind().name()))?;
let granted = &plugin.meta.lock().granted;
if !granted.contains(&Capability::ProjectRead)
|| !granted.contains(&Capability::EditorRead)
|| !filter.matches(&root, path)
{
return None;
}
let real = ide_plugin::resolve_real(path);
let relative = real.strip_prefix(ide_plugin::resolve_real(&root)).ok()?;
let mut change = change.clone();
change.path = relative.to_string_lossy().replace('\\', "/");
return Some(WorkbenchEvent::DocumentChanged(change));
}
filters
.and_then(|filters| filters.get(event.kind().name()))
.is_none_or(|filter| filter.matches(&root, path))
.then(|| event.clone())
filter.matches(&root, path).then(|| event.clone())
})
.collect()
}
@@ -189,7 +167,6 @@ mod tests {
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
assert!(PathFilter::new("file-saved", vec![]).is_ok());
assert!(PathFilter::new("files-changed", vec![]).is_ok());
assert!(PathFilter::new("document-changed", vec!["src/**/*.rs".into()]).is_ok());
}
#[test]
+1 -7
Переглянути файл
@@ -507,13 +507,7 @@ mod tests {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(
&self,
_: &Path,
_: u64,
_: &[ide_core::TextEdit],
_: bool,
) -> Result<u64, ide_plugin::HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, ide_plugin::HostError> {
unreachable!()
}
fn trusted(&self, _: &Path) -> bool {
+2 -9
Переглянути файл
@@ -27,12 +27,8 @@ pub async fn plugin_navigation_files(
if files.len() > 20_000 {
return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files"));
}
// A project window group's members are project scope: their files are
// in the group's index (the group root holds none of them).
let members = crate::project::workspace_member_roots(&project.root);
let in_scope = |p: &PathBuf| p.starts_with(&project.root) || members.iter().any(|m| p.starts_with(m));
Ok(NavigationFiles {
paths: files.into_iter().filter(in_scope).map(Into::into).collect(),
paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(),
indexing,
extensions,
})
@@ -68,14 +64,11 @@ pub async fn plugin_navigation_index(
if !project.is_trusted() {
return Err(IdeError::unavailable("project is not trusted"));
}
// A project window group's members are project scope.
let mut roots = vec![project.root.clone()];
roots.extend(crate::project::workspace_member_roots(&project.root));
for path in paths {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let Ok(path) = ide_plugin::resolve_in_roots(&roots, &path.to_string_lossy()) else {
let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else {
continue;
};
let extensions = plugins.navigation_extensions(&project.root);
+16 -115
Переглянути файл
@@ -14,7 +14,6 @@ use ide_extension::{MANIFEST_FILE, Manifest};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// The official marketplace: the index the release workflow publishes with
/// the app, next to the signed packages of the official plugins.
@@ -94,29 +93,22 @@ fn safe_suggestions(paths: Vec<String>) -> Vec<String> {
paths.into_iter().filter(ok).take(MAX_SUGGESTIONS).collect()
}
/// Whether `url` is read over the network: `http(s)://…`, not `file://…` or a local path.
fn remote(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
/// Whether an index read from `base` may send the IDE to `href` (resolved). One read over
/// the network names nothing on this machine: the IDE would read the file for it, or sign
/// in to the share it names.
pub fn reachable_from(base: &str, href: &str) -> bool {
!remote(base) || remote(href)
}
/// The slowest transfer a read over the network waits for, in bytes per second: with
/// `READ_GRACE`, the largest index is given up on after about five minutes, the largest
/// package after a little over an hour.
const SLOWEST_READ: usize = 64 << 10;
/// What a read over the network may take before its first byte.
const READ_GRACE: Duration = Duration::from_secs(30);
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
if remote(url) {
return read_remote(url, max, READ_GRACE + Duration::from_secs((max / SLOWEST_READ) as u64));
if url.starts_with("https://") || url.starts_with("http://") {
let client = ide_http::client::shared().map_err(|e| e.message)?;
let url = url.to_owned();
return super::api::net_runtime().block_on(async move {
let response = client.get(&url).send().await.map_err(|e| format!("{url}: {e}"))?;
if !response.status().is_success() {
return Err(format!("{url}: HTTP {}", response.status()));
}
let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?;
if bytes.len() > max {
return Err(format!("{url}: larger than {} MiB", max >> 20));
}
Ok(bytes.to_vec())
});
}
let path = url.strip_prefix("file://").unwrap_or(url);
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
@@ -126,35 +118,6 @@ pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
Ok(bytes)
}
/// Reads `url` over http(s): no more than `max` bytes, whatever the server announces or
/// sends, and within `deadline` from the request to the last byte. The marketplace is read
/// with nobody asking, so its server decides neither how much is kept nor for how long.
fn read_remote(url: &str, max: usize, deadline: Duration) -> Result<Vec<u8>, String> {
let client = ide_http::client::shared().map_err(|e| e.message)?;
super::api::net_runtime().block_on(async move {
let failed = |e: reqwest::Error| match e.is_timeout() {
true => format!("{url}: no answer in time"),
false => format!("{url}: {e}"),
};
let too_large = || format!("{url}: larger than {} MiB", max >> 20);
let mut response = client.get(url).timeout(deadline).send().await.map_err(failed)?;
if !response.status().is_success() {
return Err(format!("{url}: HTTP {}", response.status()));
}
if response.content_length().is_some_and(|announced| announced > max as u64) {
return Err(too_large());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(failed)? {
if bytes.len() + chunk.len() > max {
return Err(too_large());
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
})
}
/// The index at `url`, following `moved`, with downloads made absolute against
/// where it was read from.
fn read_index(url: &str) -> IdeResult<Index> {
@@ -169,25 +132,12 @@ fn read_index(url: &str) -> IdeResult<Index> {
if url.starts_with("https://") && !next.starts_with("https://") {
return Err(IdeError::invalid(format!("the marketplace moved to {next}, which isn't https")));
}
if !reachable_from(&url, &next) {
return Err(IdeError::invalid("the marketplace moved to an address that is not on the network"));
}
url = next;
}
None => {
for e in &mut index.plugins {
e.download = resolve(&url, &e.download);
}
// Judged here, against where this index was read: the address in use may be
// an index on this machine that moved to it.
let listed = index.plugins.len();
index.plugins.retain(|e| reachable_from(&url, &e.download));
if index.plugins.len() < listed {
tracing::warn!(
"plugin marketplace: {} downloads are not on the network; they are not offered",
listed - index.plugins.len()
);
}
return Ok(index);
}
}
@@ -345,23 +295,13 @@ impl PluginHost {
entry.name, entry.version, entry.engine
)));
}
let download = resolve(&url, &entry.download);
// An index read by this version holds only downloads it may reach. The one kept by an
// earlier version does not say where it was read: it is judged against the marketplace in use.
if !reachable_from(&url, &download) {
return Err(IdeError::invalid(format!(
"the marketplace's download of {} {} is not on the network",
entry.id, entry.version
)));
}
let bytes = read_url(&download, MAX_PACKAGE).map_err(IdeError::unavailable)?;
let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?;
let downloads = self.plugins_dir.join(DOWNLOADS);
std::fs::create_dir_all(&downloads)?;
let archive = downloads.join(format!("{}-{}.{}", entry.id, entry.version, package::PACKAGE_EXTENSION));
std::fs::write(&archive, bytes)?;
let dir = self.unpack_package(&archive);
let dir = self.unpack_package(&archive)?;
let _ = std::fs::remove_file(&archive);
let dir = dir?;
let manifest = Manifest::load(&dir).map_err(IdeError::invalid)?;
let files = self.package_files_in(&dir)?;
let digest = package::package_digest(&dir, &files)?;
@@ -568,43 +508,4 @@ mod tests {
assert_eq!(safe_suggestions(paths.iter().map(|p| p.to_string()).collect()), ["pom.xml", "config/routes.rb"]);
assert_eq!(safe_suggestions(vec!["a".into(); 20]).len(), MAX_SUGGESTIONS);
}
#[test]
fn an_index_read_over_the_network_reaches_only_the_network() {
let base = "https://x.dev/market/index.json";
let reaches = |href: &str| reachable_from(base, &resolve(base, href));
for href in ["p/a.oiplugin", "../a.oiplugin", "https://cdn.dev/a"] {
assert!(reaches(href), "{href}");
}
for href in ["/etc/passwd", "//attacker/share/a", "file:///etc/passwd", "file://attacker/share/a"] {
assert!(!reaches(href), "{href}");
}
#[cfg(windows)]
for href in [r"C:\Users\me\.ssh\id_ed25519", r"\\attacker\share\a", r"\\?\UNC\attacker\share\a"] {
assert!(!reaches(href), "{href}");
}
// A local index names what whoever put it there chose.
assert!(reachable_from("/srv/market/index.json", "/srv/market/a.oiplugin"));
}
#[test]
fn a_server_that_stops_answering_does_not_hold_the_read() {
use std::io::{BufRead, BufReader, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://127.0.0.1:{}/index.json", listener.local_addr().unwrap().port());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
line.clear();
}
// The headers, then nothing for longer than the read waits.
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n");
std::thread::sleep(Duration::from_secs(5));
}
});
let err = read_remote(&url, MAX_INDEX, Duration::from_millis(300)).unwrap_err();
assert!(err.contains("no answer in time"), "{err}");
}
}
+1 -15
Переглянути файл
@@ -41,7 +41,6 @@ pub mod language_features;
mod market;
mod mcp;
pub mod navigation;
mod network_inbox;
mod points;
mod providers;
pub mod remote;
@@ -55,7 +54,6 @@ mod types;
pub mod ui_points;
pub mod views;
pub mod web;
mod websocket;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub(crate) use hover::merge as merge_hover;
@@ -65,7 +63,7 @@ pub use icons::{
pub(crate) use inlay_hints::merge as merge_inlay_hints;
pub use market::{
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
reachable_from as market_href_reachable_from, read_url as read_market_url, resolve as resolve_market_href,
read_url as read_market_url, resolve as resolve_market_href,
};
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
pub use providers::{ProviderKind, set_editor_providers};
@@ -1383,17 +1381,6 @@ impl PluginHost {
invocation: Invocation,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<Option<String>> {
// Queued behind a prompt open in another window, the command would sit there with no
// sign of life until that prompt is answered: point the user at it instead.
if let Some(client) = invocation.client.as_deref()
&& let Some(waiting) = self.shared.waiting_elsewhere(id, client)
{
self.sink.emit(Some(&waiting), PluginEvent::PromptWaiting { plugin_id: id.to_owned() });
let name = self.get(id).map(|p| p.manifest().name.clone()).unwrap_or_else(|_| id.to_owned());
return Err(IdeError::invalid(format!(
"{name} is waiting for your answer in another window. Answer or close it there, then try again."
)));
}
let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned));
self.code_call_cancellable(id, invocation, "running the command", cancel, move |rt, inv| {
rt.run_command(&cmd, arg.as_deref(), inv)
@@ -1923,7 +1910,6 @@ impl PluginHost {
for p in plugins {
let wanted: Vec<WorkbenchEvent> = events
.iter()
.filter(|event| !matches!(event, WorkbenchEvent::WebsocketReady(ready) if ready.plugin_id != p.id()))
.filter(|e| Self::wants(&p, e.kind(), subs.kinds.get(p.id())))
.filter(|e| {
!matches!(e, WorkbenchEvent::FilesChanged(_))
+6 -41
Переглянути файл
@@ -121,14 +121,9 @@ pub(crate) struct NavigationIndex {
fn contains(range: Range, pos: Pos) -> bool {
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
}
/// Inside the document, start not after end. An empty range marks a position (a template file's
/// definition at its start, say): navigation lands there, but it has no text to rename.
fn valid_range(lines: &[usize], range: Range) -> bool {
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) <= (range.end.line, range.end.col)
}
fn is_empty(range: Range) -> bool {
(range.start.line, range.start.col) == (range.end.line, range.end.col)
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
}
impl PluginHost {
@@ -209,11 +204,7 @@ impl PluginHost {
if text.len() > MAX_DOCUMENT_BYTES {
return Err(IdeError::invalid("navigation document is too large"));
}
// A project window group's members are project scope: a member's file
// is indexed into the group's graph like any file of the group root.
let mut roots = vec![root.to_path_buf()];
roots.extend(crate::project::workspace_member_roots(root));
let real = ide_plugin::resolve_in_roots(&roots, &path.to_string_lossy()).map_err(IdeError::invalid)?;
let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?;
if real != path {
return Err(IdeError::invalid("navigation requires a canonical project path"));
}
@@ -285,20 +276,12 @@ impl PluginHost {
.chain(&edges.references)
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid(format!(
"{}: invalid navigation anchors in {}",
plugin.id(),
path.display()
)));
return Err(IdeError::invalid("invalid navigation anchors"));
}
if edges.markers.len() > MAX_EDGES
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid(format!(
"{}: invalid navigation markers in {}",
plugin.id(),
path.display()
)));
return Err(IdeError::invalid("invalid navigation markers"));
}
let mut index = self.navigation.lock();
if index.epochs.get(root).copied() != generation {
@@ -419,7 +402,7 @@ impl PluginHost {
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
for candidate in &file.edges.definitions {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
@@ -427,7 +410,7 @@ impl PluginHost {
}
}
let mut matches = HashMap::new();
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
for candidate in &file.edges.references {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
@@ -593,21 +576,3 @@ impl ExtensionPoint for IndexersPoint {
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ranges_inside_the_document_are_valid_positions() {
let lines = [3, 0];
let range = |a: (u32, u32), b: (u32, u32)| Range::new(Pos::new(a.0, a.1), Pos::new(b.0, b.1));
assert!(valid_range(&lines, range((0, 0), (0, 0))));
assert!(valid_range(&lines, range((1, 0), (1, 0))));
assert!(valid_range(&lines, range((0, 1), (0, 3))));
assert!(!valid_range(&lines, range((0, 2), (0, 1))), "reversed");
assert!(!valid_range(&lines, range((0, 4), (0, 4))), "past the line end");
assert!(!valid_range(&lines, range((2, 0), (2, 0))), "past the last line");
assert!(is_empty(range((0, 0), (0, 0))) && !is_empty(range((0, 0), (0, 1))));
}
}
-203
Переглянути файл
@@ -1,203 +0,0 @@
//! Bounded inbox for the proposed background network API.
//!
//! Experimental transport-independent primitive. This module does not expose
//! sockets to plugins. Overflow closes the inbox: silently dropping a document
//! update would make a collaboration session appear healthy while diverging.
use std::collections::VecDeque;
use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Push {
/// The caller must schedule one plugin wake-up after releasing this lock.
Wake,
Queued,
/// This message was refused and the caller must schedule a wake-up.
OverflowWake,
/// This message was refused; a wake-up is already pending.
OverflowQueued,
Closed,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Batch {
pub messages: Vec<Vec<u8>>,
pub closed: bool,
pub more: bool,
}
#[derive(Default)]
struct State {
messages: VecDeque<Box<[u8]>>,
bytes: usize,
closed: bool,
notified: bool,
}
pub(super) struct Inbox {
state: Mutex<State>,
max_messages: usize,
max_bytes: usize,
max_frame: usize,
}
impl Inbox {
pub fn new(max_messages: usize, max_bytes: usize, max_frame: usize) -> Self {
assert!(max_messages > 0 && max_bytes > 0 && max_frame > 0);
Self { state: Mutex::new(State::default()), max_messages, max_bytes, max_frame }
}
pub fn push(&self, message: Vec<u8>) -> Push {
let mut state = self.state.lock().unwrap();
if state.closed {
return Push::Closed;
}
let overflow = message.len() > self.max_frame
|| state.messages.len() >= self.max_messages
|| message.len() > self.max_bytes.saturating_sub(state.bytes);
if overflow {
state.closed = true;
} else {
state.bytes += message.len();
state.messages.push_back(message.into_boxed_slice());
}
let wake = !state.notified;
state.notified = true;
match (overflow, wake) {
(false, true) => Push::Wake,
(false, false) => Push::Queued,
(true, true) => Push::OverflowWake,
(true, false) => Push::OverflowQueued,
}
}
/// Returns whether the caller must schedule a wake-up. Idempotent.
pub fn close(&self) -> bool {
let mut state = self.state.lock().unwrap();
if state.closed {
return false;
}
state.closed = true;
let wake = !state.notified;
state.notified = true;
wake
}
/// A partial drain retains the notification; the consumer must continue
/// draining when `more` is true. An empty drain rearms the next wake-up.
pub fn drain(&self, limit: usize) -> Batch {
assert!(limit > 0);
let mut state = self.state.lock().unwrap();
let count = limit.min(state.messages.len());
let messages: Vec<Vec<u8>> = state.messages.drain(..count).map(|message| message.into_vec()).collect();
state.bytes -= messages.iter().map(Vec::len).sum::<usize>();
let more = !state.messages.is_empty();
state.notified = more;
Batch { messages, closed: state.closed, more }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coalesces_and_rearms_wakeups() {
let inbox = Inbox::new(4, 16, 8);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2]), Push::Queued);
assert!(inbox.drain(1).more);
assert_eq!(inbox.push(vec![3]), Push::Queued);
assert_eq!(inbox.drain(4).messages, vec![vec![2], vec![3]]);
assert_eq!(inbox.push(vec![4]), Push::Wake);
}
#[test]
fn overflow_preserves_accepted_messages_and_reports_close() {
for (messages, bytes, frame) in [(1, 8, 8), (4, 1, 8), (4, 8, 1)] {
let inbox = Inbox::new(messages, bytes, frame);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2, 3]), Push::OverflowQueued);
let batch = inbox.drain(4);
assert_eq!(batch.messages, vec![vec![1]]);
assert!(batch.closed);
assert_eq!(inbox.push(vec![]), Push::Closed);
}
}
#[test]
fn accepted_frame_does_not_retain_excess_capacity() {
let inbox = Inbox::new(2, 8, 8);
let mut frame = Vec::with_capacity(1024 * 1024);
frame.push(42);
assert_eq!(inbox.push(frame), Push::Wake);
let state = inbox.state.lock().unwrap();
assert_eq!(state.bytes, 1);
assert_eq!(state.messages[0].len(), 1);
assert_eq!(std::mem::size_of_val(&*state.messages[0]), 1);
}
#[test]
fn close_during_partial_drain_remains_visible_without_duplicate_wakeup() {
let inbox = Inbox::new(3, 12, 4);
assert_eq!(inbox.push(vec![1]), Push::Wake);
assert_eq!(inbox.push(vec![2]), Push::Queued);
let first = inbox.drain(1);
assert_eq!(first.messages, vec![vec![1]]);
assert!(first.more);
assert!(!inbox.close());
let second = inbox.drain(1);
assert_eq!(second.messages, vec![vec![2]]);
assert!(second.closed);
assert!(!second.more);
assert_eq!(inbox.push(vec![3]), Push::Closed);
}
#[test]
fn draining_releases_byte_budget_for_later_messages() {
let inbox = Inbox::new(2, 4, 4);
assert_eq!(inbox.push(vec![1, 2, 3, 4]), Push::Wake);
assert_eq!(inbox.drain(1).messages, vec![vec![1, 2, 3, 4]]);
assert_eq!(inbox.push(vec![5, 6, 7, 8]), Push::Wake);
assert_eq!(inbox.drain(1).messages, vec![vec![5, 6, 7, 8]]);
}
#[test]
fn oversized_first_message_requests_wakeup_and_reports_refusal() {
let inbox = Inbox::new(2, 8, 1);
assert_eq!(inbox.push(vec![1, 2]), Push::OverflowWake);
let batch = inbox.drain(2);
assert!(batch.closed);
assert!(batch.messages.is_empty());
}
#[test]
fn close_wakes_once_even_without_messages() {
let inbox = Inbox::new(4, 16, 8);
assert!(inbox.close());
assert!(!inbox.close());
assert!(inbox.drain(4).closed);
assert_eq!(inbox.push(vec![1]), Push::Closed);
}
#[test]
fn concurrent_producers_remain_bounded_and_fifo_per_producer() {
let inbox = std::sync::Arc::new(Inbox::new(400, 800, 2));
std::thread::scope(|scope| {
for producer in 0..4u8 {
let inbox = inbox.clone();
scope.spawn(move || {
for index in 0..100u8 {
assert_ne!(inbox.push(vec![producer, index]), Push::Closed);
}
});
}
});
let batch = inbox.drain(400);
assert_eq!(batch.messages.len(), 400);
assert!(!batch.closed);
for producer in 0..4u8 {
let sequence: Vec<_> = batch.messages.iter().filter(|m| m[0] == producer).map(|m| m[1]).collect();
assert_eq!(sequence, (0..100u8).collect::<Vec<_>>());
}
}
}
-2
Переглянути файл
@@ -155,7 +155,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::TaskProgress { .. }
| PluginEvent::TaskEnded { .. }
| PluginEvent::SubscriptionsChanged
| PluginEvent::WebsocketReady { .. }
| PluginEvent::ViewChanged { .. }
| PluginEvent::ViewMessage { .. }
| PluginEvent::OpenTerminal { .. }
@@ -166,7 +165,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
-7
Переглянути файл
@@ -275,9 +275,6 @@ pub enum PluginEvent {
/// The kinds of workbench events plugins want changed: re-fetch
/// `plugin_event_kinds` (the UI reports only those).
SubscriptionsChanged,
/// A background WebSocket has messages ready for its owning plugin.
#[serde(rename_all = "camelCase")]
WebsocketReady { plugin_id: String, connection: u64 },
#[serde(rename_all = "camelCase")]
ThemeChanged { theme: Option<ActiveTheme> },
/// A plugin needs capabilities the user has not granted yet (first use
@@ -324,10 +321,6 @@ pub enum PluginEvent {
/// The call that asked gave up (cancelled, or its plugin stopped): close the prompt.
#[serde(rename_all = "camelCase")]
PromptClosed { prompt_id: String },
/// A command of this window's prompting plugin was run from another window and refused
/// while the plugin waits for the answer here: bring this window to the front.
#[serde(rename_all = "camelCase")]
PromptWaiting { plugin_id: String },
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
-531
Переглянути файл
@@ -1,531 +0,0 @@
//! Host-owned background WebSocket connections for plugins.
use super::{
PluginEvent, PluginEventSink,
network_inbox::{Inbox, Push},
};
use futures::{SinkExt, StreamExt};
use ide_plugin::WebSocketBatch;
use parking_lot::Mutex;
use std::{
collections::HashMap,
sync::{
Arc, Weak,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use tokio::sync::{Notify, watch};
use tokio_tungstenite::{
Connector, connect_async_tls_with_config,
tungstenite::{
Message,
client::IntoClientRequest,
http::{HeaderName, HeaderValue},
protocol::WebSocketConfig,
},
};
const MAX_MESSAGES: usize = 256;
const MAX_BYTES: usize = 8 << 20;
const MAX_FRAME: usize = 1 << 20;
const DRAIN_BATCH: usize = 32;
const CLOSED_HANDLE_TTL: Duration = Duration::from_secs(60);
const MAX_CONNECTIONS_PER_PLUGIN: usize = 8;
pub type ConnectionId = u64;
#[derive(Debug, PartialEq, Eq)]
pub enum ConnectError {
Invalid(String),
Failed(String),
}
struct Connection {
plugin: String,
outgoing: Arc<Inbox>,
incoming: Arc<Inbox>,
wake: Notify,
stop: watch::Sender<bool>,
}
struct Inner {
next: AtomicU64,
connections: Mutex<HashMap<ConnectionId, Arc<Connection>>>,
pending: Mutex<HashMap<String, usize>>,
sink: Option<Arc<dyn PluginEventSink>>,
}
#[derive(Clone)]
pub struct WebSocketHub(Arc<Inner>);
impl Default for WebSocketHub {
fn default() -> Self {
Self(Arc::new(Inner {
next: AtomicU64::new(1),
connections: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
sink: None,
}))
}
}
impl WebSocketHub {
pub fn new(sink: Arc<dyn PluginEventSink>) -> Self {
Self(Arc::new(Inner {
next: AtomicU64::new(1),
connections: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
sink: Some(sink),
}))
}
pub async fn connect(
&self,
plugin: &str,
_project: &str,
url: &str,
headers: &[(String, String)],
subprotocol: &str,
timeout: Duration,
) -> Result<ConnectionId, ConnectError> {
validate_url(url).map_err(ConnectError::Invalid)?;
if subprotocol.len() > 256 {
return Err(ConnectError::Invalid("WebSocket subprotocol is too long".into()));
}
if timeout.is_zero() {
return Err(ConnectError::Failed("WebSocket connection timed out".into()));
}
self.reserve(plugin)?;
let result = async {
let mut request =
url.into_client_request().map_err(|_| ConnectError::Invalid("invalid WebSocket URL".into()))?;
for (name, value) in headers {
if protected_header(name) {
return Err(ConnectError::Invalid(format!(
"WebSocket header {name:?} is managed by the handshake"
)));
}
let name: HeaderName =
name.parse().map_err(|_| ConnectError::Invalid("invalid WebSocket header name".into()))?;
let value = HeaderValue::from_str(value)
.map_err(|_| ConnectError::Invalid("invalid WebSocket header value".into()))?;
request.headers_mut().append(name, value);
}
if !subprotocol.is_empty() {
request.headers_mut().insert(
"sec-websocket-protocol",
subprotocol.parse().map_err(|_| ConnectError::Invalid("invalid WebSocket subprotocol".into()))?,
);
}
let config = WebSocketConfig::default()
.read_buffer_size(16 << 10)
.write_buffer_size(16 << 10)
.max_write_buffer_size(2 << 20)
.max_message_size(Some(MAX_FRAME))
.max_frame_size(Some(MAX_FRAME));
let tls = ide_http::client::tls_config()
.map(Arc::new)
.map(Connector::Rustls)
.map_err(|error| ConnectError::Failed(error.message))?;
let (socket, response) =
tokio::time::timeout(timeout, connect_async_tls_with_config(request, Some(config), false, Some(tls)))
.await
.map_err(|_| ConnectError::Failed("WebSocket connection timed out".into()))?
.map_err(|_| ConnectError::Failed("WebSocket connection failed".into()))?;
if !subprotocol.is_empty()
&& response.headers().get("sec-websocket-protocol").and_then(|value| value.to_str().ok())
!= Some(subprotocol)
{
return Err(ConnectError::Failed("WebSocket server did not accept the required subprotocol".into()));
}
let id = self.0.next.fetch_add(1, Ordering::Relaxed);
let (stop, stopped) = watch::channel(false);
let connection = Arc::new(Connection {
plugin: plugin.to_owned(),
outgoing: Arc::new(Inbox::new(MAX_MESSAGES, MAX_BYTES, MAX_FRAME)),
incoming: Arc::new(Inbox::new(MAX_MESSAGES, MAX_BYTES, MAX_FRAME)),
wake: Notify::new(),
stop,
});
self.0.connections.lock().insert(id, connection.clone());
tokio::spawn(run_socket(id, socket, connection, Arc::downgrade(&self.0), stopped));
Ok(id)
}
.await;
self.release(plugin);
result
}
pub fn send(&self, plugin: &str, connection: ConnectionId, bytes: Vec<u8>) -> Result<(), String> {
let connection = self.connection(plugin, connection)?;
match connection.outgoing.push(bytes) {
Push::Wake => {
connection.wake.notify_one();
Ok(())
}
Push::Queued => Ok(()),
Push::OverflowWake => {
connection.wake.notify_one();
Err("WebSocket send queue is full; message was rejected".into())
}
Push::OverflowQueued => Err("WebSocket send queue is full; message was rejected".into()),
Push::Closed => Err("WebSocket connection is closed; message was rejected".into()),
}
}
pub fn drain(&self, plugin: &str, connection: ConnectionId, limit: usize) -> Result<WebSocketBatch, String> {
if limit == 0 || limit > DRAIN_BATCH {
return Err("invalid drain limit".into());
}
let connection_state = self.connection(plugin, connection)?;
let batch = connection_state.incoming.drain(limit);
if batch.more {
self.emit_ready(connection, plugin);
}
if batch.closed && !batch.more {
self.0.connections.lock().remove(&connection);
}
Ok(WebSocketBatch { messages: batch.messages, closed: batch.closed })
}
pub fn disconnect(&self, plugin: &str, id: ConnectionId) -> Result<(), String> {
let connection = self
.0
.connections
.lock()
.get(&id)
.filter(|connection| connection.plugin == plugin)
.cloned()
.ok_or_else(|| "unknown WebSocket connection".to_owned())?;
connection.outgoing.close();
let _ = connection.stop.send(true);
connection.wake.notify_one();
self.0.connections.lock().remove(&id);
Ok(())
}
pub fn disconnect_plugin(&self, plugin: &str) {
let removed: Vec<_> = {
let mut connections = self.0.connections.lock();
let ids: Vec<_> =
connections.iter().filter(|(_, connection)| connection.plugin == plugin).map(|(id, _)| *id).collect();
ids.into_iter().filter_map(|id| connections.remove(&id)).collect()
};
for connection in removed {
connection.outgoing.close();
let _ = connection.stop.send(true);
connection.wake.notify_one();
}
}
fn connection(&self, plugin: &str, id: ConnectionId) -> Result<Arc<Connection>, String> {
self.0
.connections
.lock()
.get(&id)
.filter(|connection| connection.plugin == plugin)
.cloned()
.ok_or_else(|| "unknown WebSocket connection".into())
}
fn reserve(&self, plugin: &str) -> Result<(), ConnectError> {
let open = self.0.connections.lock().values().filter(|connection| connection.plugin == plugin).count();
let mut pending = self.0.pending.lock();
if open + pending.get(plugin).copied().unwrap_or_default() >= MAX_CONNECTIONS_PER_PLUGIN {
return Err(ConnectError::Invalid("at most 8 WebSocket connections per plugin".into()));
}
*pending.entry(plugin.to_owned()).or_default() += 1;
Ok(())
}
fn release(&self, plugin: &str) {
let mut pending = self.0.pending.lock();
if let Some(count) = pending.get_mut(plugin) {
*count -= 1;
if *count == 0 {
pending.remove(plugin);
}
}
}
fn emit_ready(&self, connection: ConnectionId, plugin: &str) {
if let Some(sink) = &self.0.sink {
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: plugin.to_owned(), connection });
}
}
}
fn validate_url(url: &str) -> Result<(), String> {
let parsed = reqwest::Url::parse(url).map_err(|_| "invalid WebSocket URL")?;
// `host_str` keeps an IPv6 address in brackets.
let loopback = parsed.host_str().is_some_and(|host| {
let host = host.trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
});
if parsed.scheme() != "wss" && !(parsed.scheme() == "ws" && loopback) {
return Err("WebSocket requires WSS outside loopback".into());
}
if !parsed.username().is_empty() || parsed.password().is_some() || parsed.fragment().is_some() {
return Err("WebSocket URLs may not carry credentials or a fragment".into());
}
Ok(())
}
fn protected_header(name: &str) -> bool {
name.eq_ignore_ascii_case("host")
|| name.eq_ignore_ascii_case("connection")
|| name.eq_ignore_ascii_case("upgrade")
|| name.get(.."sec-websocket-".len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case("sec-websocket-"))
}
async fn run_socket<S>(
id: ConnectionId,
socket: tokio_tungstenite::WebSocketStream<S>,
connection: Arc<Connection>,
inner: Weak<Inner>,
mut stopped: watch::Receiver<bool>,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let (mut writer, mut reader) = socket.split();
loop {
tokio::select! {
_ = connection.wake.notified() => {
let batch = connection.outgoing.drain(DRAIN_BATCH);
let mut failed = false;
for bytes in batch.messages {
if writer.send(Message::Binary(bytes.into())).await.is_err() {
failed = true;
break;
}
}
if failed || batch.closed { break; }
if batch.more { connection.wake.notify_one(); }
}
changed = stopped.changed() => {
if changed.is_err() || *stopped.borrow() {
let mut failed = false;
loop {
let batch = connection.outgoing.drain(DRAIN_BATCH);
for bytes in batch.messages {
if writer.send(Message::Binary(bytes.into())).await.is_err() {
failed = true;
break;
}
}
if failed || !batch.more { break; }
}
break;
}
}
message = reader.next() => match message {
Some(Ok(Message::Binary(bytes))) => {
let pushed = connection.incoming.push(bytes.to_vec());
if matches!(pushed, Push::Wake | Push::OverflowWake)
&& let Some(inner) = inner.upgrade()
&& let Some(sink) = &inner.sink
{
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: connection.plugin.clone(), connection: id });
}
if matches!(pushed, Push::OverflowWake | Push::OverflowQueued | Push::Closed) { break; }
}
Some(Ok(Message::Ping(bytes))) => if writer.send(Message::Pong(bytes)).await.is_err() { break; },
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
_ => {}
}
}
}
let _ = writer.close().await;
connection.outgoing.close();
let wake = connection.incoming.close();
if let Some(inner) = inner.upgrade() {
if wake && let Some(sink) = &inner.sink {
sink.emit(None, PluginEvent::WebsocketReady { plugin_id: connection.plugin.clone(), connection: id });
}
let retained = connection.clone();
tokio::spawn(async move {
tokio::time::sleep(CLOSED_HANDLE_TTL).await;
let mut connections = inner.connections.lock();
if connections.get(&id).is_some_and(|current| Arc::ptr_eq(current, &retained)) {
connections.remove(&id);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
use tokio_tungstenite::{
accept_hdr_async,
tungstenite::handshake::server::{ErrorResponse, Request, Response},
};
#[allow(clippy::result_large_err)] // Required by tungstenite's callback signature.
fn accept_test_protocol(request: &Request, mut response: Response) -> Result<Response, ErrorResponse> {
assert_eq!(request.headers()["authorization"], "Bearer test");
assert_eq!(request.headers()["sec-websocket-protocol"], "openidea.test.v1");
response.headers_mut().insert("sec-websocket-protocol", "openidea.test.v1".parse().unwrap());
Ok(response)
}
async fn server() -> (String, tokio::task::JoinHandle<Vec<Vec<u8>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let socket = accept_hdr_async(stream, accept_test_protocol).await.unwrap();
let (_, mut reader) = socket.split();
let mut messages = Vec::new();
while let Ok(Some(message)) = tokio::time::timeout(Duration::from_secs(2), reader.next()).await {
match message.unwrap() {
Message::Binary(bytes) => messages.push(bytes.to_vec()),
Message::Close(_) => break,
_ => {}
}
}
messages
});
(url, task)
}
#[tokio::test]
async fn outbound_message_reaches_the_websocket_server() {
let (url, server) = server().await;
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("authorization".into(), "Bearer test".into())],
"openidea.test.v1",
Duration::from_secs(1),
)
.await
.unwrap();
hub.send("plugin", connection, b"message".to_vec()).unwrap();
hub.disconnect("plugin", connection).unwrap();
assert_eq!(server.await.unwrap(), [b"message"]);
}
#[tokio::test]
async fn inbound_messages_can_be_drained_and_disconnect_cleans_the_handle() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(stream, accept_test_protocol).await.unwrap();
socket.send(Message::Binary(vec![1, 2, 3].into())).await.unwrap();
socket.close(None).await.unwrap();
});
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("authorization".into(), "Bearer test".into())],
"openidea.test.v1",
Duration::from_secs(1),
)
.await
.unwrap();
server.await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(
hub.drain("plugin", connection, 1).unwrap(),
WebSocketBatch { messages: vec![vec![1, 2, 3]], closed: true }
);
assert!(hub.drain("plugin", connection, 1).is_err());
}
#[test]
fn send_rejects_full_queues_without_reporting_success() {
let hub = WebSocketHub::default();
let (stop, _) = watch::channel(false);
let connection = Arc::new(Connection {
plugin: "plugin".into(),
outgoing: Arc::new(Inbox::new(1, MAX_BYTES, MAX_FRAME)),
incoming: Arc::new(Inbox::new(1, MAX_BYTES, MAX_FRAME)),
wake: Notify::new(),
stop,
});
hub.0.connections.lock().insert(1, connection.clone());
assert!(hub.send("other-plugin", 1, b"cross-plugin".to_vec()).is_err());
assert!(hub.drain("other-plugin", 1, 1).is_err());
assert!(hub.send("plugin", 1, b"first".to_vec()).is_ok());
assert!(hub.send("plugin", 1, b"second".to_vec()).unwrap_err().contains("rejected"));
assert_eq!(connection.outgoing.drain(1).messages, [b"first"]);
}
#[test]
fn websocket_urls_require_tls_and_keep_credentials_out_of_the_url() {
assert!(validate_url("wss://relay.example/socket").is_ok());
assert!(validate_url("wss://relay.example/socket?channel=main&token=opaque").is_ok());
assert!(validate_url("ws://127.0.0.1:9000/socket").is_ok());
assert!(validate_url("ws://[::1]:9000/socket").is_ok());
assert!(validate_url("ws://localhost:9000/socket").is_ok());
assert!(validate_url("ws://relay.example/socket").unwrap_err().contains("WSS"));
assert!(validate_url("wss://[email protected]/socket").is_err());
assert!(validate_url("wss://relay.example/socket#fragment").is_err());
assert!(protected_header("Host"));
assert!(protected_header("Connection"));
assert!(protected_header("Upgrade"));
assert!(protected_header("Sec-WebSocket-Key"));
assert!(!protected_header("Cookie"));
assert!(!protected_header("X-API-Key"));
}
#[tokio::test]
#[allow(clippy::result_large_err)] // Required by tungstenite's callback signature.
async fn optional_subprotocol_and_custom_headers_reach_the_handshake() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/socket?channel=main", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_hdr_async(stream, |request: &Request, response: Response| {
assert_eq!(request.headers()["cookie"], "session=test");
assert_eq!(request.headers()["x-api-key"], "key");
assert!(request.headers().get("sec-websocket-protocol").is_none());
Ok(response)
})
.await
.unwrap();
socket.close(None).await.unwrap();
});
let hub = WebSocketHub::default();
let connection = hub
.connect(
"plugin",
"/project",
&url,
&[("cookie".into(), "session=test".into()), ("x-api-key".into(), "key".into())],
"",
Duration::from_secs(1),
)
.await
.unwrap();
hub.disconnect("plugin", connection).unwrap();
server.await.unwrap();
}
#[test]
fn connection_limit_is_enforced_before_connecting() {
let hub = WebSocketHub::default();
for _ in 0..MAX_CONNECTIONS_PER_PLUGIN {
hub.reserve("plugin").unwrap();
}
assert_eq!(
hub.reserve("plugin"),
Err(ConnectError::Invalid("at most 8 WebSocket connections per plugin".into()))
);
for _ in 0..MAX_CONNECTIONS_PER_PLUGIN {
hub.release("plugin");
}
}
}
+2 -73
Переглянути файл
@@ -13,7 +13,7 @@ use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -50,8 +50,6 @@ pub struct Project {
pub cache_dir: PathBuf,
/// Superseded launch snapshots retained until this project session ends.
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
/// Reasons this window was already told why Kotlin is debugged with kotlin-debug-adapter.
pub(crate) kotlin_debug_notices: Mutex<HashSet<&'static str>>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -69,8 +67,6 @@ pub struct Project {
/// watchers for (a virtualenv elsewhere, a path dependency).
lsp_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
pub(crate) _scratch_watcher: Option<Watcher>,
/// Picks up data sources edited on disk outside the Database tool window.
_db_sources_watch: Option<ide_db::SourcesWatch>,
/// A LightEdit window's loose files ([`Host::open_light`]): global settings only,
/// nothing indexed, no language servers, repositories or project services.
pub light: bool,
@@ -250,7 +246,6 @@ impl Host {
&bundles,
lombok.jar(),
);
ide_sdk::apply::configure_gradle_runtime(cfg, root, server_root, &effective);
ide_sdk::jdtls::launch_directly(cfg);
}
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
@@ -538,12 +533,10 @@ impl Host {
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
let (db, http_history, agents) = self.project_services(&root, &project_key, &settings);
let db_sources_watch = db_sources_watch(&db, &sink, &self.runtime);
let notebooks = crate::notebook::NotebookService::new(
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
notebooks.set_observer(Arc::new(NotebookLspObserver(lsp.clone())));
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
let plugin_diagnostics =
crate::plugins::diagnostics::Diagnostics::new(sink.clone(), self.runtime.clone(), &documents, &root);
@@ -569,7 +562,6 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -580,7 +572,6 @@ impl Host {
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: scratch_watcher,
_db_sources_watch: db_sources_watch,
files_events_active,
light: false,
});
@@ -604,12 +595,6 @@ impl Host {
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.agents.set_security_hook(Arc::new(move || {
weak.upgrade().map(|p| p.settings.effective().agent_security).unwrap_or_default()
}));
}
self.insert_project(window, project.clone());
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
@@ -732,7 +717,6 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -743,7 +727,6 @@ impl Host {
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
_scratch_watcher: None,
_db_sources_watch: None,
files_events_active: Arc::new(AtomicBool::new(false)),
light: true,
root: dir,
@@ -796,28 +779,6 @@ fn documents(sink: &SharedSink, languages: &Arc<LanguageRegistry>, root: Option<
)
}
/// Data sources edited on disk outside the Database tool window (by hand, by a coding
/// agent): connections made with the old settings close and the tree re-reads them,
/// without reopening the project.
fn db_sources_watch(
db: &Arc<ide_db::DbManager>,
sink: &SharedSink,
runtime: &tokio::runtime::Handle,
) -> Option<ide_db::SourcesWatch> {
let (weak, sink, runtime) = (Arc::downgrade(db), sink.clone(), runtime.clone());
db.watch_sources(Box::new(move || {
let Some(db) = weak.upgrade() else { return };
let sink = sink.clone();
runtime.spawn(async move {
if db.reconcile_sources().await {
sink.emit(IdeEvent::DbSourcesChanged);
}
});
}))
.inspect_err(|e| tracing::warn!("{}", e.message))
.ok()
}
fn light_watcher(
dir: &Path,
sink: &SharedSink,
@@ -1226,19 +1187,6 @@ fn index_roots(root: &Path, folders: Vec<PathBuf>) -> ide_index::IndexRoots {
ide_index::IndexRoots { include_root: !container, folders: folders.into_iter().filter(|p| p != root).collect() }
}
/// The member roots of an explicit workspace container (empty for a plain
/// project): how the editor and the language servers resolve a file's own
/// project in a project window group. Also the roots the plugin surface may
/// reach into — see `plugins::api`.
pub(crate) fn workspace_member_roots(root: &Path) -> Vec<PathBuf> {
ide_workspace::workspace::Workspace::load(root)
.ok()
.flatten()
.filter(|w| w.is_container(root))
.map(|w| w.projects)
.unwrap_or_default()
}
/// Workspace members in manifest order, or extra Project Structure directories and the
/// Gradle project folders outside the project as of the last sync (`build_cache`).
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
@@ -1259,7 +1207,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Project Structure › Dependencies —
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
@@ -1606,25 +1554,6 @@ impl DocumentObserver for LspObserver {
}
}
/// Feeds the code cells of open notebooks to the Python language servers that sync notebooks.
struct NotebookLspObserver(Arc<LspManager>);
impl crate::notebook::NotebookObserver for NotebookLspObserver {
fn changed(&self, path: &Path, snapshot: &ide_notebook::NotebookSnapshot) {
let cells: Vec<ide_lsp::NotebookCell> = snapshot
.cells
.iter()
.filter(|c| c.cell_type == "code")
.map(|c| ide_lsp::NotebookCell { id: c.id.clone(), source: c.source.clone() })
.collect();
// Notebooks run Python kernels only.
self.0.sync_notebook(path, &LanguageId::new("python"), &cells);
}
fn closed(&self, path: &Path) {
self.0.close_notebook(path);
}
}
/// Feeds document edits into the per-project syntax tree cache.
struct SyntaxObserver(Arc<SyntaxCache>);
-1
Переглянути файл
@@ -254,7 +254,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "jdk_download_releases"
| "jdk_download_location"
| "jdk_download"
| "jdtls_required_java"
| "run_system_env"
| "run_gradle_jvm_args_script"
| "proxy_check_connection"
-218
Переглянути файл
@@ -209,49 +209,6 @@ fn write_index(
path.to_string_lossy().into_owned()
}
/// Security audit lead 5: an index entry's id and version name the download file,
/// so an entry whose id or version would place it outside the downloads folder
/// is not offered, and nothing is written for it.
#[test]
fn market_entry_ids_cannot_place_the_download_outside_the_downloads_folder() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (_, hello) = signed_hello(&market, "1.0.0", &key, &f.host);
// Never read: an entry that is not offered is not downloaded.
std::fs::write(market.join("junk.bin"), b"attacker bytes").unwrap();
// The id leaves the folder on every platform (`.downloads` exists by then); the version
// only where `..` is resolved before the path is opened (Windows).
for (id, version) in [("../../escape", "1.0.0"), ("acme.hello", "1.0.0/../../../escape")] {
let entry = IndexEntry { id: id.into(), version: version.into(), download: "junk.bin".into(), ..hello.clone() };
let url = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(url));
f.host.refresh_market().unwrap();
assert!(f.host.market_plugins().is_empty(), "{id} {version} is offered");
let err = f.host.download_market_plugin(id).unwrap_err();
assert!(err.message.contains("is not in the marketplace"), "{id} {version}: {}", err.message);
}
let downloads = f.plugins_dir.join(".downloads");
let mut escaped = Vec::new();
let mut pending = vec![f._tmp.path().to_path_buf()];
while let Some(dir) = pending.pop() {
for e in std::fs::read_dir(&dir).unwrap().flatten() {
let path = e.path();
if path.is_dir() {
pending.push(path);
} else if path.extension().is_some_and(|x| x == "oiplugin")
&& !path.starts_with(&downloads)
&& !path.starts_with(&market)
{
escaped.push(path);
}
}
}
assert!(escaped.is_empty(), "the download was written outside {}: {escaped:?}", downloads.display());
}
/// 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]
@@ -409,181 +366,6 @@ fn a_moved_marketplace_is_read_where_it_moved_to() {
assert!(err.message.contains("moved more than"), "{}", err.message);
}
/// A loopback HTTP server whose answer to every request `respond` writes; the URL of its index.
fn serve_with(respond: impl Fn(&mut std::net::TcpStream) + Send + 'static) -> String {
use std::io::{BufRead, BufReader};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://127.0.0.1:{}/index.json", listener.local_addr().unwrap().port());
std::thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 && line != "\r\n" {
line.clear();
}
respond(&mut stream);
}
});
url
}
/// A loopback HTTP server answering every request with `body`; the URL of its index.
fn serve(body: String) -> String {
use std::io::Write;
serve_with(move |stream| {
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
})
}
/// An index is read up to the size an index may have, whatever its server says or sends:
/// the marketplace is read again every day, with nobody asking.
#[test]
fn an_index_larger_than_an_index_may_be_is_not_read_to_its_end() {
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
let f = fixture();
// Announced: refused on its headers, before any of it is read.
f.host.set_market_url(Some(serve_with(|stream| {
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1073741824\r\nConnection: close\r\n\r\n{");
})));
let err = f.host.refresh_market().unwrap_err();
assert!(err.message.contains("larger than 16 MiB"), "{}", err.message);
// Not announced: the read stops at the limit, and the server is left with the rest.
const BODY: usize = 96 << 20;
let sent = Arc::new(AtomicUsize::new(0));
f.host.set_market_url(Some(serve_with({
let sent = sent.clone();
move |stream| {
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n");
let chunk = [b' '; 64 << 10];
let mut written = 0;
while written < BODY && stream.write_all(&chunk).is_ok() {
written += chunk.len();
}
sent.store(written.max(1), Ordering::SeqCst);
}
})));
let err = f.host.refresh_market().unwrap_err();
assert!(err.message.contains("larger than 16 MiB"), "{}", err.message);
let waiting = std::time::Instant::now();
while sent.load(Ordering::SeqCst) == 0 && waiting.elapsed() < std::time::Duration::from_secs(30) {
std::thread::sleep(std::time::Duration::from_millis(20));
}
let sent = sent.load(Ordering::SeqCst);
assert!(sent > 0 && sent < BODY, "the server sent {sent} of {BODY} bytes");
}
/// An index read over the network names downloads on the network: not a file of this
/// machine, nor a share the machine would sign in to.
#[test]
fn an_index_read_over_the_network_cannot_name_a_local_download() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (package, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let path = package.to_string_lossy().into_owned();
// A package that installs, so only where the index was read from can refuse it.
for download in [path.clone(), format!("file://{path}")] {
let entry = IndexEntry { download: download.clone(), ..entry.clone() };
let local = write_index(&market, &key, vec![entry], vec![], false);
f.host.set_market_url(Some(local.clone()));
f.host.refresh_market().unwrap();
let dir = f.host.download_market_plugin("acme.hello").unwrap();
assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0", "{download}");
f.host.set_market_url(Some(serve(std::fs::read_to_string(&local).unwrap())));
assert!(f.host.refresh_market().unwrap().is_empty(), "{download} is offered");
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("is not in the marketplace"), "{download}: {}", err.message);
}
}
/// Nor when an index on this machine is what sent the IDE to the one on the network.
#[test]
fn an_index_reached_through_a_local_one_cannot_name_a_local_download() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (package, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let entry = IndexEntry { download: package.to_string_lossy().into_owned(), ..entry };
let served = serve(std::fs::read_to_string(write_index(&market, &key, vec![entry], vec![], false)).unwrap());
let old = f.work.join("old");
std::fs::create_dir_all(&old).unwrap();
let moved = serde_json::json!({ "format": package::INDEX_FORMAT, "moved": served }).to_string();
std::fs::write(old.join("index.json"), moved).unwrap();
f.host.set_market_url(Some(old.join("index.json").to_string_lossy().into_owned()));
assert!(f.host.refresh_market().unwrap().is_empty(), "the local download is offered");
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("is not in the marketplace"), "{}", err.message);
}
/// The index kept from the last read does not say where it was read from: what an earlier
/// version kept is judged against the marketplace in use.
#[test]
fn a_kept_index_cannot_name_a_local_download_for_a_marketplace_on_the_network() {
let f = fixture();
let market = f.work.join("market");
std::fs::create_dir_all(&market).unwrap();
let key = PublisherKey::from_seed([3; 32]);
let (package, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
let entry = IndexEntry { download: package.to_string_lossy().into_owned(), ..entry };
let kept = write_index(&market, &key, vec![entry], vec![], false);
std::fs::copy(kept, f.plugins_dir.join("market-index.json")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// Never read: the check comes before any request.
again.set_market_url(Some("https://market.invalid/index.json".into()));
assert_eq!(again.market_plugins()[0].entry.id, "acme.hello");
let err = again.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("is not on the network"), "{}", err.message);
}
/// Nor may it send the IDE to an index on this machine.
#[test]
fn an_index_read_over_the_network_cannot_move_to_a_local_one() {
let f = fixture();
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 local = write_index(&market, &key, vec![entry], vec![], false);
let moved = serde_json::json!({ "format": package::INDEX_FORMAT, "moved": local }).to_string();
f.host.set_market_url(Some(serve(moved)));
let err = f.host.refresh_market().unwrap_err();
assert!(err.message.contains("is not on the network"), "{}", err.message);
assert!(f.host.market_plugins().is_empty(), "the local index was read");
}
/// A download that turns out not to be a package is not kept.
#[test]
fn a_download_that_is_not_a_package_leaves_nothing_behind() {
let f = fixture();
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);
std::fs::write(market.join("junk.bin"), b"not a package").unwrap();
let entry = IndexEntry { download: "junk.bin".into(), ..entry };
f.host.set_market_url(Some(write_index(&market, &key, vec![entry], vec![], false)));
f.host.refresh_market().unwrap();
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
assert!(err.message.contains("not a plugin package"), "{}", err.message);
let left: Vec<_> = std::fs::read_dir(f.plugins_dir.join(".downloads"))
.map(|dir| dir.flatten().map(|e| e.file_name()).collect())
.unwrap_or_default();
assert!(left.is_empty(), "{left:?}");
}
/// `dir/index.json` listing `publishers` and `plugins`; its path.
fn index_of(dir: &Path, publishers: Vec<Publisher>, plugins: Vec<IndexEntry>) -> String {
let index = Index { format: package::INDEX_FORMAT, publishers, plugins, ..Index::default() };
+2 -8
Переглянути файл
@@ -93,15 +93,9 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
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 uri = format!("git-revision:{hex}/deleted.txt");
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
assert_eq!(document.encoding, "utf-8");
assert!(document.can_reload_encoding);
// Reload with Encoding re-decodes the blob with the chosen charset.
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
assert_eq!(reloaded.encoding, "utf-16le");
assert_ne!(reloaded.text, document.text);
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());
-37
Переглянути файл
@@ -416,40 +416,3 @@ async fn rust_references_and_rename_through_mcp() {
assert!(project.documents.text_snapshot(doc.id).unwrap().text.contains("format_amount"));
assert!(std::fs::read_to_string(h.root.join("src/main.rs")).unwrap().contains("format_amount"));
}
#[tokio::test(flavor = "multi_thread")]
async fn brave_mode_runs_tools_without_asking_only_from_the_global_settings() {
let h = Harness::new();
h.trust().await;
h.host.mcp_requests.set_ready("main", true);
let p = h.host.project("main").unwrap();
// A project's own settings can't turn the confirmations off.
let project_layer =
p.settings.set(ide_workspace::SettingsScope::Project, "mcpServer.runWithoutConfirmation", json!(true));
assert!(project_layer.is_err());
let denied = h.error("build_project", json!({}));
let answer = async {
let id = h.request().await;
h.host.mcp_requests.answer("main", id, false, None).unwrap();
};
let (error, ()) = tokio::join!(denied, answer);
assert!(error.contains("denied by the user"), "{error}");
h.events.lock().unwrap().clear();
h.host
.global_settings
.set(ide_workspace::SettingsScope::Global, "mcpServer.runWithoutConfirmation", json!(true))
.unwrap();
let (host, root) = (h.host.clone(), h.root.clone());
let result = tokio::time::timeout(
std::time::Duration::from_secs(60),
tokio::task::spawn_blocking(move || {
Registry::new(host).call(&root, "build_project", "{}", CancellationToken::new())
}),
)
.await
.expect("ran without waiting for an answer")
.unwrap();
assert!(!format!("{result:?}").contains("denied"), "{result:?}");
assert!(!h.events.lock().unwrap().iter().any(|e| matches!(e, IdeEvent::McpRequest { .. })), "nothing asked");
}
-70
Переглянути файл
@@ -1,70 +0,0 @@
//! An image path may come from Markdown someone else wrote, and on Windows opening a network
//! share signs in to its server with the user's credentials: the host reads an image from a
//! share only when a project is open on it. The share here is this machine's own `C$` over
//! loopback.
#![cfg(windows)]
use ide_core::NullSink;
use ide_host::ops::bytes::fs_read_image;
use ide_host::{Host, HostConfig};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// `path` as seen through the admin share of `server`, a name of this machine.
fn over_smb(server: &str, path: &Path) -> PathBuf {
let s = path.to_str().unwrap();
PathBuf::from(format!(r"\\{server}\{}${}", &s[..1], &s[2..]))
}
#[test]
fn images_on_a_share_are_read_only_where_a_project_is_open() {
let tmp = tempfile::tempdir().unwrap();
let local = dunce::canonicalize(tmp.path()).unwrap();
let root = local.join("project");
std::fs::create_dir_all(root.join("docs")).unwrap();
for image in [root.join("docs/a.png"), local.join("outside.png")] {
std::fs::write(image, b"\x89PNG dummy").unwrap();
}
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let _guard = runtime.enter();
let host = 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,
});
let read = |path: PathBuf| runtime.block_on(fs_read_image(&host, "main", path));
let in_project = over_smb("127.0.0.1", &root.join("docs/a.png"));
let beside_project = over_smb("127.0.0.1", &local.join("outside.png"));
let other_server = over_smb("localhost", &root.join("docs/a.png"));
for path in [&in_project, &beside_project, &other_server] {
assert!(std::fs::read(path).is_ok(), "control: {} is not reachable over SMB", path.display());
}
let wanted = read(root.join("docs/a.png")).unwrap();
// No project is open on any share.
for path in [&in_project, &beside_project, &other_server] {
assert!(read(path.clone()).is_err(), "read {} with no project on its share", path.display());
}
let project_root = over_smb("127.0.0.1", &root);
host.trust.decide(&project_root, true, false).unwrap();
host.open_project("main", &project_root).unwrap();
assert_eq!(read(in_project.clone()).unwrap(), wanted);
// The whole share is one sign-in: a file outside the project folder is on it too.
assert_eq!(read(beside_project.clone()).unwrap(), wanted);
assert_eq!(read(PathBuf::from(in_project.to_str().unwrap().replace('\\', "/"))).unwrap(), wanted);
// The path as the webview holds it: the project root it was given, joined with `/`.
let root_in_webview = ide_core::ipc_path::to_ipc(&host.project("main").unwrap().root);
assert_eq!(read(PathBuf::from(format!("{root_in_webview}/docs/a.png"))).unwrap(), wanted);
assert!(read(other_server.clone()).is_err(), "read {} from a server no project is on", other_server.display());
host.close_project("main");
assert!(read(in_project.clone()).is_err(), "read {} after its project closed", in_project.display());
}
+1 -1
Переглянути файл
@@ -39,7 +39,7 @@ impl PluginWorkspace for Workspace {
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit], _: bool) -> Result<u64, HostError> {
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
Err(HostError::NotFound("not open".into()))
}
fn trusted(&self, _: &Path) -> bool {
+6 -450
Переглянути файл
@@ -8,7 +8,6 @@
use ide_core::error::ErrorKind;
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_host::{Host, HostConfig};
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
@@ -154,20 +153,9 @@ impl PluginWorkspace for TestWorkspace {
let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?;
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(
&self,
path: &Path,
version: u64,
edits: &[ide_core::TextEdit],
background: bool,
) -> Result<u64, HostError> {
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
let doc = self.documents.by_path(path).ok_or_else(|| HostError::NotFound("not open".into()))?;
let applied = if background {
self.documents.apply_background_edits_at(doc, version as u32, edits)
} else {
self.documents.apply_edits_at(doc, version as u32, edits)
};
match applied {
match self.documents.apply_edits_at(doc, version as u32, edits) {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) => Err(HostError::Failed(e.message)),
@@ -466,10 +454,10 @@ fn edits_are_refused_when_the_document_changed() {
range: ide_core::Range::new(ide_core::Pos::new(0, 0), ide_core::Pos::new(0, 3)),
new_text: "ONE".into(),
};
assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit), false), Err(HostError::Stale));
assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit)), Err(HostError::Stale));
assert_eq!(f.workspace.text("src/notes.txt"), "typed one two three\nfour five\n");
let now = f.workspace.document(&editor.path).unwrap().0;
assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit], false), Ok(now + 1));
assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit]), Ok(now + 1));
assert_eq!(f.workspace.text("src/notes.txt"), "ONEed one two three\nfour five\n");
let _ = (
std::any::type_name::<Runtime>(),
@@ -819,28 +807,6 @@ fn a_reporting_task_outlives_its_background_budget() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
let f = fixture(Limits {
fuel: 3_500,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
});
f.install(&misbehave_pkg());
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
}
#[test]
fn private_directory_is_owned_persistent_and_removed_on_uninstall() {
let f = fixture(Limits {
@@ -4190,35 +4156,6 @@ fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget(
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
}
#[test]
fn a_command_from_another_window_is_refused_while_the_plugin_waits_for_an_answer() {
let (f, doc) = interactive_actions_fixture();
let action = f
.host
.provide_code_actions(&doc, ide_core::Range::default())
.into_iter()
.find(|(_, action)| action.data.as_deref() == Some("pick"))
.unwrap()
.1;
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
// Queued behind the open prompt it would not run until the prompt is answered.
let other = Invocation { client: Some("other-window".into()), ..Default::default() };
let err = f.host.run_command("misbehave", "anything", None, other).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
assert!(err.message.contains("waiting for your answer in another window"), "{}", err.message);
let events = f.take_events();
assert!(
events.iter().any(|(client, e)| client.as_deref() == Some("editor-window")
&& matches!(e, PluginEvent::PromptWaiting { plugin_id } if plugin_id == "misbehave")),
"the prompting window is brought forward: {events:?}"
);
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
running.join().unwrap().unwrap();
});
}
#[test]
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
for (how, kind) in [
@@ -4840,14 +4777,8 @@ impl PluginWorkspace for EndpointWorkspace {
fn project_of(&self, _: &str) -> Option<PathBuf> {
self.roots.lock().first().cloned()
}
fn apply_edits(
&self,
path: &Path,
version: u64,
edits: &[ide_core::TextEdit],
background: bool,
) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits, background)
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits)
}
fn publish_diagnostics(
&self,
@@ -6404,378 +6335,3 @@ fn api14_stage3_terminal_calls_are_denied_without_process_grants() {
f.host.set_capability("execution", "process:execution-tool", false).unwrap();
assert!(f.run("execution", "terminal", None).unwrap_err().contains("not granted"));
}
// ─── Project window groups ────────────────────────────────────────────────────
// A group's container root holds only `.openidea` metadata; the files live in
// the member projects. The plugin surface reaches them like the editor and the
// language servers do.
/// A real host over a project window group with the members `alpha` (holding a
/// nested `inner` member, for deepest resolution) and `beta`.
struct GroupFixture {
base: PathBuf,
host: Arc<Host>,
plugins: Arc<PluginHost>,
root: PathBuf,
alpha: PathBuf,
inner: PathBuf,
beta: PathBuf,
_runtime: tokio::runtime::Runtime,
_tmp: tempfile::TempDir,
}
impl GroupFixture {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let (root, alpha, beta) = (base.join("workspace"), base.join("alpha"), base.join("beta"));
let inner = alpha.join("inner");
for dir in [alpha.join("src"), inner.join("mappers"), beta.join("src")] {
std::fs::create_dir_all(dir).unwrap();
}
ide_workspace::workspace::Workspace::save(
&root,
"Group",
vec![alpha.clone(), inner.clone(), beta.clone()],
true,
)
.unwrap();
std::fs::write(alpha.join("src/needle.php"), "alpha needle\n").unwrap();
std::fs::write(inner.join("mappers/route.idx"), "def route:item\nref route:item\n").unwrap();
std::fs::write(beta.join("src/Notes.txt"), "beta needle\n").unwrap();
std::fs::write(base.join("secret.txt"), "top secret").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
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(ide_core::NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
for dir in [&root, &alpha, &beta] {
host.trust.decide(dir, true, false).unwrap();
}
{
let _guard = runtime.enter();
let project = host.open_project("main", &root).unwrap();
assert!(project.index.wait_idle(Duration::from_secs(30)));
}
let plugins = Arc::new(
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(),
);
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
plugins.set_project_policy(Arc::new(|_| true));
Self { base, host, plugins, root, alpha, inner, beta, _runtime: runtime, _tmp: tmp }
}
/// Runs an installed plugin's `command` with `root` (default: the group
/// container) as the invoking project.
fn run(&self, id: &str, command: &str, arg: Option<&str>, root: Option<&Path>) -> Result<Option<String>, String> {
let inv = Invocation {
client: Some("main".into()),
workspace_root: Some(dunce::canonicalize(root.unwrap_or(&self.root)).unwrap()),
..Default::default()
};
self.plugins.run_command(id, command, arg, inv).map_err(|e| e.message)
}
/// Installs `dir` with everything granted.
fn install(&self, dir: &Path) {
self.plugins.install_from_dir(dir, &Consent::all()).unwrap();
}
/// The roots the plugin surface lists, sorted and deduplicated.
fn listed_roots(&self) -> Vec<PathBuf> {
HostWorkspace(Arc::downgrade(&self.host)).projects()
}
}
impl Drop for GroupFixture {
fn drop(&mut self) {
self.host.close_project("main");
}
}
/// `projects()` lists the members with their own roots, and the member roots of
/// the container are exactly the workspace manifest's.
#[test]
fn projects_list_the_workspace_members() {
let f = GroupFixture::new();
assert_eq!(
f.listed_roots(),
[f.alpha.clone(), f.inner.clone(), f.beta.clone(), f.root.clone()]
.iter()
.map(|p| dunce::canonicalize(p).unwrap())
.collect::<Vec<_>>()
);
assert_eq!(
HostWorkspace(Arc::downgrade(&f.host)).member_roots(&f.root),
[f.alpha.clone(), f.inner.clone(), f.beta.clone()]
.iter()
.map(|p| dunce::canonicalize(p).unwrap())
.collect::<Vec<_>>()
);
// A member has no members of its own.
assert!(HostWorkspace(Arc::downgrade(&f.host)).member_roots(&f.alpha).is_empty());
}
/// `project-of(path)` resolves to the deepest member project containing the
/// path, where the container and every member appear.
#[test]
fn project_of_resolves_to_the_deepest_workspace_member() {
let f = GroupFixture::new();
f.install(&sample("path-events"));
let project =
|path: &Path| f.run("test.path-events", "project", Some(path.to_str().unwrap()), None).unwrap().unwrap();
assert_eq!(project(&f.alpha.join("src/needle.php")), to_ipc(&f.alpha));
// The nested member, not its containing member.
assert_eq!(project(&f.inner.join("mappers/route.idx")), to_ipc(&f.inner));
assert_eq!(project(&f.beta.join("src/Notes.txt")), to_ipc(&f.beta));
// The container itself is still a project.
assert_eq!(project(&f.root), to_ipc(&f.root));
// And a path in no project resolves to none.
assert!(
f.run("test.path-events", "project", Some(f.base.join("secret.txt").to_str().unwrap()), None)
.unwrap()
.is_none()
);
}
/// `project.read` and `project.write` reach the member files through the
/// group's project, and never leave the group's roots.
#[test]
fn project_reads_and_writes_reach_workspace_members() {
let f = GroupFixture::new();
f.install(&misbehave_with(f._tmp.path(), "misbehave-group-writer", "project.write"));
let notes = f.beta.join("src/Notes.txt");
assert_eq!(f.run("misbehave", "read", Some(notes.to_str().unwrap()), None).unwrap().unwrap(), "beta needle\n");
// Writing into a member creates its missing parent directories.
let fresh = f.beta.join("src/generated/mapper.xml");
f.run("misbehave", "projwrite", Some(fresh.to_str().unwrap()), None).unwrap();
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written inside the project\n");
// `ctx.project()` is still the group's project.
assert_eq!(f.run("misbehave", "root", None, None).unwrap().unwrap(), to_ipc(&f.root));
// What is outside every root of the group stays refused, unwritten.
let outside = f.base.join("secret.txt");
let err = f.run("misbehave", "read", Some(outside.to_str().unwrap()), None).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let err = f.run("misbehave", "projwrite", Some(outside.to_str().unwrap()), None).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
assert_eq!(std::fs::read_to_string(outside).unwrap(), "top secret");
// A member-scoped project reads its own files only: its root is the member.
assert_eq!(f.run("misbehave", "read", Some("src/Notes.txt"), Some(&f.beta)).unwrap().unwrap(), "beta needle\n");
}
/// Find in Files covers the members: search from the container finds member
/// matches, a member-scoped project sees only its own.
#[test]
fn project_search_reaches_workspace_members() {
let f = GroupFixture::new();
f.install(&sample("api14"));
let search = |root: &Path, needle: &str| f.run("api14", "search-text", Some(needle), Some(root)).unwrap().unwrap();
// From the container: matches in the members come through.
let all = search(&f.root, "needle");
assert!(all.contains("needle.php"), "{all}");
assert!(all.contains("Notes.txt"), "{all}");
// The container holds no sources: `.openidea` metadata never matches.
assert!(!all.contains("workspace.json"), "{all}");
// From a member: only that member's matches.
let own = search(&f.beta, "needle");
assert!(own.contains("Notes.txt"), "{own}");
assert!(!own.contains("needle.php"), "{own}");
// What is outside every root is never found.
assert!(!search(&f.root, "top secret").contains("secret.txt"));
}
/// The document-indexer covers member files: discovery offers them, indexing
/// one publishes its navigation, and files outside the group stay out.
#[test]
fn document_indexer_covers_workspace_members() {
let f = GroupFixture::new();
f.install(&sample("indexer"));
let mapping = f.inner.join("mappers/route.idx");
// Discovery: the member file is in the window's project scope.
let files = f
._runtime
.block_on(ide_host::plugins::index_ops::plugin_navigation_files(&f.host, f.plugins.clone(), "main"))
.unwrap();
assert!(files.paths.iter().any(|p| p.0 == mapping), "{:?}", files.paths);
// Indexing the member file through the window's project publishes it: the
// open document is indexed at its version, like an editor click resolves.
let project = f.host.project("main").unwrap();
let doc = project.documents.open(&mapping).unwrap();
f._runtime
.block_on(ide_host::plugins::index_ops::plugin_navigation_index(
&f.host,
f.plugins.clone(),
"main",
vec![mapping.clone()],
ide_core::RequestId::next(),
))
.unwrap();
let result = f
.plugins
.navigation_at(&f.root, &mapping, doc.version, ide_core::Pos::new(1, 5), Some("main"), |_| None)
.unwrap();
assert_eq!(result.targets[0].path.0, mapping);
// A file outside the group is never offered, and indexing skips it.
let outside = f.base.join("escape.idx");
std::fs::write(&outside, "def outside:item\n").unwrap();
let files = f
._runtime
.block_on(ide_host::plugins::index_ops::plugin_navigation_files(&f.host, f.plugins.clone(), "main"))
.unwrap();
assert!(!files.paths.iter().any(|p| p.0 == outside), "{:?}", files.paths);
f._runtime
.block_on(ide_host::plugins::index_ops::plugin_navigation_index(
&f.host,
f.plugins.clone(),
"main",
vec![outside.clone()],
ide_core::RequestId::next(),
))
.unwrap();
assert!(f.plugins.navigation_at(&f.root, &outside, 1, ide_core::Pos::new(0, 4), Some("main"), |_| None).is_none());
}
/// Advisory #4: `project.write` (Medium) must not reach VCS metadata such as `.git/hooks` or
/// `.git/config`, which git runs as code; the `.git` is inside the project root, so root
/// confinement alone does not stop it.
#[test]
fn project_write_cannot_reach_git_metadata() {
let f = fixture(Limits::default());
let init = std::process::Command::new("git").current_dir(&f.project).args(["init", "-q"]).output().unwrap();
assert!(init.status.success(), "git init");
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
// The grant works for an ordinary path, so each refusal below is the `.git` rule and nothing else.
assert_eq!(f.run("misbehave", "projwrite", Some("src/fresh.txt")).unwrap(), None);
for rel in [".git/hooks/pre-commit", ".git/config", ".GIT/config", "src/../.git/config"] {
let before = std::fs::read(f.project.join(rel)).ok();
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
assert_eq!(std::fs::read(f.project.join(rel)).ok(), before, "{rel}");
}
}
/// In a linked worktree or a submodule `.git` is a file naming the real git folder. Replacing it,
/// under any name that opens it, would point git at a folder of the plugin's choosing or leave the
/// checkout without a repository.
#[test]
fn project_write_cannot_replace_the_git_file_of_a_linked_worktree() {
let f = fixture(Limits::default());
let pointer = f.project.join(".git");
let gitdir = "gitdir: /elsewhere/.git/worktrees/project\n";
std::fs::write(&pointer, gitdir).unwrap();
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
assert_eq!(f.run("misbehave", "projwrite", Some("src/fresh.txt")).unwrap(), None);
let mut spellings = vec![".git", ".GIT", ".git.", ".git ", ".git::$DATA"];
// Windows opens a file by its short name too, on a volume that keeps them.
if cfg!(windows) && f.project.join("GIT~1").exists() {
spellings.extend(["GIT~1", "git~1", "GIT~1.", "GIT~1 ", "GIT~1::$DATA"]);
} else {
eprintln!("short name not tried: this volume keeps none");
}
for rel in spellings {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&pointer).unwrap(), gitdir, "{rel}");
}
}
#[cfg(unix)]
#[test]
fn process_timeout_returns_guest_error_without_disabling_plugin() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits {
timeout: Duration::from_millis(120),
max_failures: 3,
restart_backoff: Duration::ZERO,
..Limits::default()
});
let package = f._tmp.path().join("api14-slow-proc");
copy_dir(&sample("api14"), &package);
let manifest = package.join("plugin.toml");
let text =
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
std::fs::write(manifest, text).unwrap();
f.install(&package);
let slow = f._tmp.path().join("slow-tool");
std::fs::write(&slow, "#!/bin/sh\nsleep 5\n").unwrap();
std::fs::set_permissions(&slow, std::fs::Permissions::from_mode(0o755)).unwrap();
let slow = slow.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&slow)).unwrap();
// Even past `max_failures` (3), external subprocess timeouts unwind as guest errors
// instead of poisoning the Wasm instance and disabling the plugin for the session.
for _ in 0..4 {
let err = f.run("api14", "process-run", None).unwrap_err();
assert!(err.contains("api14-tool timed out"), "{err}");
}
let info = f.host.info_of("api14").unwrap();
assert_eq!(info.status, PluginStatus::Active);
assert_eq!(info.stats.crashes, 0);
}
#[cfg(unix)]
#[test]
fn process_stdout_accepts_large_payloads_and_reports_overflow_explicitly() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits { timeout: Duration::from_secs(10), ..Limits::default() });
let package = f._tmp.path().join("api14-large-proc");
copy_dir(&sample("api14"), &package);
let manifest = package.join("plugin.toml");
let text =
std::fs::read_to_string(&manifest).unwrap().replace("optional = [", "optional = [\"process:api14-tool\", ");
std::fs::write(manifest, text).unwrap();
f.install(&package);
let medium_bytes = 5 * 1024 * 1024;
let medium = f._tmp.path().join("medium-tool");
std::fs::write(&medium, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=5 2>/dev/null | tr '\\0' 'x'\n").unwrap();
std::fs::set_permissions(&medium, std::fs::Permissions::from_mode(0o755)).unwrap();
let medium = medium.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&medium)).unwrap();
let out = f.run("api14", "process-run", None).unwrap().unwrap();
assert_eq!(out.len(), medium_bytes);
let huge = f._tmp.path().join("huge-tool");
std::fs::write(&huge, "#!/bin/sh\ndd if=/dev/zero bs=1048576 count=17 2>/dev/null | tr '\\0' 'x'\n").unwrap();
std::fs::set_permissions(&huge, std::fs::Permissions::from_mode(0o755)).unwrap();
let huge = huge.canonicalize().unwrap();
f.host.choose_program("api14", "process:api14-tool", Some(&f.project), Some(&huge)).unwrap();
let err = f.run("api14", "process-run", None).unwrap_err();
assert!(err.contains("stdout exceeded 16 MiB limit"), "{err}");
assert_eq!(f.host.info_of("api14").unwrap().stats.crashes, 0);
}
/// A link already in the project is written through, so it is judged by where it points.
#[cfg(unix)]
#[test]
fn project_write_judges_a_link_by_its_target() {
let f = fixture(Limits::default());
let config = f.project.join(".git").join("config");
std::fs::create_dir(f.project.join(".git")).unwrap();
std::fs::write(&config, "[core]\n").unwrap();
let secret = f._tmp.path().join("secret.txt");
std::os::unix::fs::symlink(&config, f.project.join("alias")).unwrap();
std::os::unix::fs::symlink(&secret, f.project.join("escape")).unwrap();
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
// A folder that is not there, in front of the link, does not keep it from being followed.
for rel in ["alias", "missing/../alias"] {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("git's own files"), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&config).unwrap(), "[core]\n", "{rel}");
}
for rel in ["escape", "missing/../escape"] {
let err = f.run("misbehave", "projwrite", Some(rel)).unwrap_err();
assert!(err.contains("outside the project root"), "{rel}: {err}");
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "top secret", "{rel}");
}
}
-1
Переглянути файл
@@ -330,7 +330,6 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
-2
Переглянути файл
@@ -53,7 +53,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"jdk_download_releases",
"jdk_download_location",
"jdk_download",
"jdtls_required_java",
"scaffold_catalog",
"scaffold_options",
"scaffold_requirements",
@@ -95,7 +94,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
-308
Переглянути файл
@@ -1,308 +0,0 @@
//! Security audit lead 2: every `vcs_*` command Restricted Mode allows, run against a
//! repository whose config names a program for a signed commit, an index read, an external
//! diff, a clean filter and a missing object. None may run.
use ide_core::{NullSink, RequestId};
use ide_host::{Host, HostConfig, ops, ops::vcs};
use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::{Command, Stdio};
use std::sync::Arc;
/// Allowed in Restricted Mode and not swept: none of them starts git for the project.
const NOT_THE_PROJECT_REPOSITORY: [&str; 7] = [
"vcs_clone", // clones a URL the user gives into a new folder
"vcs_diff_hunks", // compares two texts
"vcs_detected_git", // looks for the git executable
"vcs_shelves", // the shelf and the changelists are files of the IDE
"vcs_shelf_patch",
"vcs_changelists_load",
"vcs_write_patch", // writes a patch the UI already holds
];
fn git(root: &Path, args: &[&str]) -> String {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}
fn spelled(path: &Path) -> String {
path.to_str().unwrap().replace('\\', "/")
}
struct Armed {
root: PathBuf,
first: String,
head: String,
/// A commit whose tree is missing: git asks the promisor for it.
promised: String,
}
/// A repository that starts a program of its own for a signed commit (`gpg`), an index read
/// (`fsmonitor`), a file diff (`extdiff`), a touched file (`filter`) and a missing object
/// (`promisor`). Each program leaves a file named after itself in `markers`. Other ways in
/// (textconv, process filters, merge drivers) are not armed here.
fn arm(root: &Path, markers: &Path) -> Armed {
std::fs::create_dir_all(root.join("src")).unwrap();
git(root, &["init", "-q", "-b", "main"]);
for (key, value) in [("user.name", "Test"), ("user.email", "[email protected]"), ("commit.gpgsign", "false")] {
git(root, &["config", key, value]);
}
std::fs::write(root.join("src/a.txt"), "one\ntwo\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-qm", "first"]);
let first = git(root, &["rev-parse", "HEAD"]);
std::fs::write(root.join("src/a.txt"), "one\ntwo\nthree\n").unwrap();
git(root, &["commit", "-qam", "second"]);
// HEAD again, carrying a signature header: `git log` then starts the verifier.
let body = git(root, &["cat-file", "commit", "HEAD"]);
let (headers, message) = body.split_once("\n\n").unwrap();
let signed = format!(
"{headers}\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEzBAABCAAdFiEEfake\n -----END PGP SIGNATURE-----\n\n{message}\n"
);
let mut child = Command::new("git")
.current_dir(root)
.args(["hash-object", "-t", "commit", "-w", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
std::io::Write::write_all(&mut child.stdin.take().unwrap(), signed.as_bytes()).unwrap();
let head = String::from_utf8(child.wait_with_output().unwrap().stdout).unwrap().trim().to_owned();
git(root, &["update-ref", "HEAD", &head]);
// A branch whose tip has a tree of its own, which is then dropped from the object database.
git(root, &["checkout", "-q", "-b", "promised", &first]);
std::fs::write(root.join("only-here.txt"), "promised\n").unwrap();
git(root, &["add", "."]);
git(root, &["commit", "-qm", "promised"]);
let promised = git(root, &["rev-parse", "HEAD"]);
let tree = git(root, &["rev-parse", "HEAD^{tree}"]);
git(root, &["checkout", "-q", "main"]);
std::fs::remove_file(root.join(".git/objects").join(&tree[..2]).join(&tree[2..])).unwrap();
let marker = |name: &str| spelled(&markers.join(name));
let script = |name: &str| {
let path = root.join(".git").join(format!("{name}.sh"));
std::fs::write(&path, format!("#!/bin/sh\necho x > '{}'\nexit 1\n", marker(name))).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
spelled(&path)
};
let settings = [
("log.showSignature", "true".to_owned()),
("gpg.program", script("gpg")),
("core.fsmonitor", script("fsmonitor")),
("diff.external", script("extdiff")),
("filter.marker.clean", format!("echo x > '{}'; cat", marker("filter"))),
("extensions.partialClone", "origin".to_owned()),
("remote.origin.url", spelled(root)),
("remote.origin.promisor", "true".to_owned()),
("remote.origin.partialclonefilter", "blob:none".to_owned()),
("remote.origin.uploadpack", format!("echo x > '{}'; git-upload-pack", marker("promisor"))),
];
std::fs::write(root.join(".gitattributes"), "*.txt filter=marker\n").unwrap();
// Same length as the committed text: only reading the file tells them apart.
std::fs::write(root.join("src/a.txt"), "ONE\ntwo\nthree\n").unwrap();
for (key, value) in &settings {
git(root, &["config", key, value]);
}
Armed { root: root.to_path_buf(), first, head, promised }
}
fn ran(markers: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(markers)
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn clear(markers: &Path) {
let _ = std::fs::remove_dir_all(markers);
std::fs::create_dir_all(markers).unwrap();
}
type Call<'a> = (&'static str, &'static str, Pin<Box<dyn Future<Output = bool> + 'a>>);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_vcs_commands_run_no_program_of_the_repository() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let markers = base.join("markers");
clear(&markers);
let project = arm(&base.join("project"), &markers);
let external = arm(&base.join("external"), &markers);
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,
});
host.trust.decide(&project.root, false, false).unwrap();
clear(&markers);
assert!(!host.open_project("main", &project.root).unwrap().is_trusted());
let host = &host;
let w = "main";
let file = project.root.join("src/a.txt");
let root = || Some(project.root.clone());
let log = || Some(external.root.clone());
macro_rules! call {
($command:literal, $what:literal, $call:expr) => {
($command, $what, Box::pin(async { $call.await.is_ok() }) as Pin<Box<dyn Future<Output = bool> + '_>>)
};
}
macro_rules! sync {
($command:literal, $what:literal, $call:expr) => {
($command, $what, Box::pin(async { $call.is_ok() }) as Pin<Box<dyn Future<Output = bool> + '_>>)
};
}
let calls: Vec<Call> = vec![
call!("vcs_status", "", ops::vcs::status(host, w)),
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
call!("vcs_file_at", "", vcs::vcs_file_at(host, w, file.clone(), None)),
call!("vcs_blame", "", vcs::vcs_blame(host, w, file.clone(), None, None)),
call!("vcs_block_history", "", vcs::vcs_block_history(host, w, RequestId(1), file.clone(), None, (0, 1))),
call!("vcs_log", "", vcs::vcs_log(host, w, root(), Default::default())),
call!("vcs_log_refs", "", vcs::vcs_log_refs(host, w, root())),
call!("vcs_log_users", "", vcs::vcs_log_users(host, w, root())),
call!("vcs_log_details", "", vcs::vcs_log_details(host, w, root(), project.head.clone())),
call!(
"vcs_log_details_batch",
"",
vcs::vcs_log_details_batch(
host,
w,
root(),
vec![project.head.clone(), project.first.clone()],
RequestId(2)
)
),
call!(
"vcs_revision_children",
"",
vcs::vcs_revision_children(host, w, root(), project.head.clone(), "src".into())
),
call!(
"vcs_revision_children",
"missing tree",
vcs::vcs_revision_children(host, w, root(), project.promised.clone(), String::new())
),
call!("vcs_revision_files", "", vcs::vcs_revision_files(host, w, root(), project.head.clone())),
call!("vcs_revision_files", "missing tree", vcs::vcs_revision_files(host, w, root(), project.promised.clone())),
call!("vcs_commit_files", "", vcs::vcs_commit_files(host, w, root(), project.head.clone())),
call!("vcs_commits_changes", "", vcs::vcs_commits_changes(host, w, root(), vec![project.head.clone()])),
call!("vcs_branches", "", vcs::vcs_branches(host, w, root())),
call!("vcs_stashes", "", vcs::vcs_stashes(host, w, root())),
call!("vcs_conflict_versions", "", vcs::vcs_conflict_versions(host, w, file.clone())),
call!("vcs_diff_text", "", vcs::vcs_diff_text(host, w, file.clone(), false)),
call!("vcs_config_get", "", vcs::vcs_config_get(host, w, root(), "user.name".into())),
call!("vcs_tags", "", vcs::vcs_tags(host, w, root())),
call!("vcs_remotes", "", vcs::vcs_remotes(host, w, root())),
call!("vcs_worktrees", "", vcs::vcs_worktrees(host, w, root())),
call!("vcs_diff_with_worktree", "", vcs::vcs_diff_with_worktree(host, w, root(), "HEAD".into())),
call!("vcs_compare_scope", "", vcs::vcs_compare_scope(host, w, vec![project.root.clone()])),
call!(
"vcs_compare_paths",
"",
vcs::vcs_compare_paths(host, w, vec![project.root.clone()], "HEAD".into(), None, RequestId(3))
),
call!(
"vcs_diff_revisions",
"",
vcs::vcs_diff_revisions(host, w, root(), project.first.clone(), project.head.clone())
),
call!(
"vcs_diff_numstat",
"",
vcs::vcs_diff_numstat(host, w, root(), project.first.clone(), project.head.clone(), Vec::new())
),
call!("vcs_merge_base", "", vcs::vcs_merge_base(host, w, root(), project.first.clone(), project.head.clone())),
call!("vcs_has_hook", "", vcs::vcs_has_hook(host, w, root(), "pre-push".into())),
// A worktree outside the project, opened for its history only.
call!("vcs_open_external_log", "", vcs::vcs_open_external_log(host, w, external.root.clone())),
call!("vcs_log", "external", vcs::vcs_log(host, w, log(), Default::default())),
call!("vcs_log_users", "external", vcs::vcs_log_users(host, w, log())),
call!("vcs_log_details", "external", vcs::vcs_log_details(host, w, log(), external.head.clone())),
call!(
"vcs_revision_children",
"external, missing tree",
vcs::vcs_revision_children(host, w, log(), external.promised.clone(), String::new())
),
call!("vcs_revision_files", "external", vcs::vcs_revision_files(host, w, log(), external.head.clone())),
call!("vcs_commit_files", "external", vcs::vcs_commit_files(host, w, log(), external.head.clone())),
// Last: it rewrites nothing of an existing repository, but it does touch it.
call!("vcs_init", "", vcs::vcs_init(host, w, project.root.clone())),
];
// The sweep covers what `trust.rs` allows: a newly allowed `vcs_*` command fails here until
// it is swept or listed as not touching the project's repository.
let source = include_str!("../src/trust.rs");
let start = source.find("pub fn restricted_command_allowed").unwrap();
let end = source.find("pub fn projectless_command_allowed").unwrap();
let allowed: BTreeSet<&str> = source[start..end].split('"').filter(|s| s.starts_with("vcs_")).collect();
let swept: BTreeSet<&str> = calls.iter().map(|(command, _, _)| *command).collect();
let known: BTreeSet<&str> = swept.iter().copied().chain(NOT_THE_PROJECT_REPOSITORY).collect();
assert!(allowed.len() > 30, "trust.rs was not read: {allowed:?}");
assert_eq!(allowed, known, "the sweep and Restricted Mode's allowed vcs commands differ");
for command in &known {
assert!(ide_host::trust::restricted_command_allowed(command), "{command} is not allowed in Restricted Mode");
}
let mut executed = Vec::new();
let mut failed = Vec::new();
let opening = ran(&markers);
if !opening.is_empty() {
executed.push(format!("open_project: {}", opening.join(", ")));
}
for (command, what, call) in calls {
clear(&markers);
let ok = call.await;
let name = if what.is_empty() { command.to_owned() } else { format!("{command} ({what})") };
let programs = ran(&markers);
if !programs.is_empty() {
executed.push(format!("{name}: {}", programs.join(", ")));
}
if !ok {
failed.push(name);
}
}
// Control: plain git, in the same repository, starts every one of the fixture's programs.
clear(&markers);
for args in [&["log", "-1", "--format=%H"][..], &["status", "--porcelain"], &["ls-tree", &project.promised]] {
let _ = Command::new("git").current_dir(&project.root).args(args).output().unwrap();
}
let _ = Command::new("git").current_dir(&project.root).args(["diff"]).output().unwrap();
assert_eq!(
ran(&markers),
["extdiff", "filter", "fsmonitor", "gpg", "promisor"],
"control: the fixture is not live"
);
assert!(executed.is_empty(), "Restricted Mode ran a program of the repository:\n{}", executed.join("\n"));
// Only the listings of the commit whose tree is gone have nothing to return.
assert_eq!(
failed,
[
"vcs_revision_children (missing tree)",
"vcs_revision_files (missing tree)",
"vcs_revision_children (external, missing tree)"
]
);
}
+6 -12
Переглянути файл
@@ -29,23 +29,17 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
build_with_redirect(proxy, reqwest::redirect::Policy::default())
}
/// The backend's TLS configuration: ring with the operating system's trust store.
pub fn tls_config() -> IdeResult<rustls::ClientConfig> {
/// A client with an explicit redirect policy, for callers enforcing network grants.
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
use rustls_platform_verifier::BuilderVerifierExt;
let fail = |e: String| IdeError::internal(format!("TLS client: {e}"));
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let provider = Arc::new(rustls::crypto::ring::default_provider());
rustls::ClientConfig::builder_with_provider(provider)
let tls = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| fail(e.to_string()))?
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))
.map(|builder| builder.with_no_client_auth())
}
/// A client with an explicit redirect policy, for callers enforcing network grants.
pub fn build_with_redirect(proxy: &ProxySettings, redirect: reqwest::redirect::Policy) -> IdeResult<reqwest::Client> {
let fail = |e: String| IdeError::internal(format!("HTTP client: {e}"));
let tls = tls_config()?;
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
// `localhost` is tried on IPv4 first: local servers (Ollama, dev servers)
// usually listen there, and on Windows a refused `::1` costs ~2 s before
// the system resolver's order falls back. Ports come from the URL.
-121
Переглянути файл
@@ -105,89 +105,6 @@ pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeR
Ok(path)
}
/// Where `{{name}}` is defined in the environment files that apply to `file`: the key `name`
/// in each environment (and `$shared`) of the nearest private file, then of the nearest public
/// file — the lookup order. Positions are 0-based lines and UTF-16 columns of the key, quotes
/// included.
pub fn variable_definitions(file: &Path, root: Option<&Path>, name: &str) -> Vec<ide_core::Location> {
let dirs = search_dirs(Some(file), root);
let mut out = Vec::new();
for file_name in [PRIVATE_FILE, PUBLIC_FILE] {
let Some(path) = dirs.iter().map(|d| d.join(file_name)).find(|p| p.is_file()) else { continue };
let Ok(text) = ide_vfs::load_text(&path) else { continue };
for range in environment_keys(&text.text, name) {
out.push(ide_core::Location { path: path.clone().into(), range, uri: None });
}
}
out
}
/// The ranges of keys equal to `name` one level inside the top-level object (an
/// environment's variables), found by scanning the JSON text without building a value.
fn environment_keys(text: &str, name: &str) -> Vec<ide_core::Range> {
use ide_core::{Pos, Range};
let mut found = Vec::new();
let (mut line, mut col) = (0u32, 0u32);
let mut depth = 0usize;
let mut chars = text.char_indices().peekable();
// Whether the last string closed at depth 2 may be a key: set after `{` or `,`.
let mut expect_key = false;
while let Some((_, c)) = chars.next() {
match c {
'"' => {
let start = Pos::new(line, col);
col += 1;
let mut value = String::new();
let mut escaped = false;
for (_, c) in chars.by_ref() {
col += c.len_utf16() as u32;
if escaped {
value.push(match c {
'n' => '\n',
't' => '\t',
other => other,
});
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
break;
} else {
value.push(c);
}
}
let end = Pos::new(line, col);
if depth == 2 && expect_key && value == name {
let mut rest = chars.clone();
while rest.peek().is_some_and(|(_, c)| c.is_whitespace()) {
rest.next();
}
if rest.peek().is_some_and(|(_, c)| *c == ':') {
found.push(Range { start, end });
}
}
expect_key = false;
continue;
}
'{' | '[' => {
depth += 1;
expect_key = c == '{';
}
'}' | ']' => depth = depth.saturating_sub(1),
',' => expect_key = true,
':' => expect_key = false,
'\n' => {
line += 1;
col = 0;
continue;
}
_ => {}
}
col += c.len_utf16() as u32;
}
found
}
impl Environments {
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
let dirs = search_dirs(file, root);
@@ -304,44 +221,6 @@ impl Resolver<'_> {
mod tests {
use super::*;
#[test]
fn finds_variable_keys_in_every_environment_private_first() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("api")).unwrap();
std::fs::write(
root.join(PUBLIC_FILE),
"{\n \"$shared\": { \"host\": \"shared\" },\n \"dev\": {\n \"name\": \"host\",\n \"host\": \"localhost\",\n \"ssl\": { \"host\": \"nested\" }\n },\n \"prod\": { \"hostname\": \"x\", \"host\": \"example.com\" }\n}\n",
)
.unwrap();
std::fs::write(root.join("api").join(PRIVATE_FILE), "{ \"dev\": { \"😀\": 1, \"host\": \"secret\" } }")
.unwrap();
let file = root.join("api").join("requests.http");
let found = variable_definitions(&file, Some(root), "host");
let at: Vec<_> = found
.iter()
.map(|l| {
(
l.path.0.file_name().unwrap().to_string_lossy().into_owned(),
l.range.start.line,
l.range.start.col,
l.range.end.col,
)
})
.collect();
assert_eq!(
at,
vec![
(PRIVATE_FILE.to_owned(), 0, 20, 26),
(PUBLIC_FILE.to_owned(), 1, 15, 21),
(PUBLIC_FILE.to_owned(), 4, 4, 10),
(PUBLIC_FILE.to_owned(), 7, 29, 35),
],
"values and nested objects are not definitions"
);
assert!(variable_definitions(&file, Some(root), "missing").is_empty());
}
#[test]
fn merges_private_public_and_shared() {
let envs = Environments::from_json(
-16
Переглянути файл
@@ -55,10 +55,6 @@ pub struct LanguageConfig {
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
/// Files of this language in a framework's projects that their extension alone would
/// give to another language (Django templates are `.html` files in `templates`).
#[serde(default)]
pub framework_files: Vec<FrameworkFiles>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -94,18 +90,6 @@ pub struct FrameworkUiMode {
pub root_markers: Vec<String>,
}
/// Files in a framework's folder: a file with one of `extensions` below a folder named
/// `folder`, which has a root marker in a directory above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkFiles {
pub folder: String,
/// Without the dot, compared ignoring case.
pub extensions: Vec<String>,
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+2 -17
Переглянути файл
@@ -5,11 +5,10 @@
use std::path::{Path, PathBuf};
/// The outermost ancestor of `file` containing a root marker, within
/// `project_root` (any ancestor without one: loose files). A directory counts
/// as its own ancestor: a module folder is served like the files in it.
/// `project_root` (any ancestor without one: loose files).
pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String]) -> Option<PathBuf> {
let mut best = None;
for dir in file.ancestors().skip(usize::from(!file.is_dir())) {
for dir in file.ancestors().skip(1) {
if project_root.is_some_and(|root| !dir.starts_with(root)) {
break;
}
@@ -113,20 +112,6 @@ mod tests {
assert_eq!(marked_root(Some(root), &root.join("apps/api/main.ts"), &markers), None);
}
#[test]
fn a_marked_directory_is_its_own_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let module = root.join("first");
std::fs::create_dir_all(module.join("src")).unwrap();
std::fs::write(module.join("pom.xml"), "").unwrap();
let markers = vec!["pom.xml".to_string()];
assert_eq!(marked_root(Some(root), &module.join("src/Main.java"), &markers), Some(module.clone()));
assert_eq!(marked_root(Some(root), &module, &markers), Some(module.clone()));
assert_eq!(marked_root(Some(root), &module.join("src"), &markers), Some(module));
assert_eq!(marked_root(Some(root), &root.join("second"), &markers), None);
}
#[test]
fn markers_above_the_project_root_only_count_without_one() {
let tmp = tempfile::tempdir().unwrap();
+7 -55
Переглянути файл
@@ -22,7 +22,6 @@ const BUILTIN: &[(&str, &str)] = &[
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("django-html", include_str!("../../../languages/django-html.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
@@ -372,13 +371,14 @@ impl LanguageRegistry {
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
/// language (`framework-files`): it is a framework's root marker.
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
let state = self.state();
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
}
/// Detects by Override File Type, exact file name, then file-name pattern
@@ -400,9 +400,6 @@ impl LanguageRegistry {
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return languages[*i].id.clone();
}
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
return l.id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
@@ -423,19 +420,6 @@ impl LanguageRegistry {
}
}
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
/// `files.folder` has a root marker in a directory above that folder.
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
return false;
}
path.ancestors()
.skip(1)
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
}
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
fn wildcard_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
@@ -513,7 +497,6 @@ mod tests {
"Brainfuck",
"Cobol",
"C#",
"Django Template",
"Clojure",
"ClojureScript",
"Closure Stylesheets (GSS)",
@@ -668,7 +651,6 @@ mod tests {
("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]),
("typescript", vec!["vtsls", "typescript-language-server"]),
("java", vec!["jdtls", "kmp-lsp"]),
("kotlin", vec!["kotlin-lsp", "kotlin-language-server", "kmp-lsp"]),
] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let got: Vec<(&str, bool)> = cfg
@@ -819,36 +801,6 @@ mod tests {
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn django_projects_make_html_in_templates_folders_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
std::fs::create_dir_all(root.join("site/templates")).unwrap();
std::fs::write(root.join("src/manage.py"), "").unwrap();
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
assert_eq!(detect("src/templates/base.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
// No `manage.py` above this `templates` folder.
assert_eq!(detect("site/templates/index.html"), "html");
assert_eq!(detect("page.djhtml"), "django-html");
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
assert_eq!(detect("pages/promo.html"), "html");
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
Loaded 100 of 917 files, more files were not shown because too many files have changed in this diff. Show more