дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-11 20:25:41 +02:00
Порівняти коміти
53
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
e8bc577e9d | ||
|
|
b1348ee03a | ||
|
|
e99d05d7a8 | ||
|
|
0e4cf1059a | ||
|
|
b4bcd8cce7 | ||
|
|
cb5e7081fa | ||
|
|
034b2b9f65 | ||
|
|
7ea3c59d05 | ||
|
|
6717b0948a | ||
|
|
8302cd1d6f | ||
|
|
c05670f4fd | ||
|
|
2f9658c367 | ||
|
|
40aa7e4899 | ||
|
|
fba1f02375 | ||
|
|
053ca43368 | ||
|
|
8fedc2efba | ||
|
|
2f2fa79cbe | ||
|
|
65d49619dd | ||
|
|
7f28161869 | ||
|
|
fc1493e1a4 | ||
|
|
efda6834ad | ||
|
|
e92e867019 | ||
|
|
8d8c7a481b | ||
|
|
0b3ca2fc9a | ||
|
|
2414773d61 | ||
|
|
e4181c36ea | ||
|
|
5063f6756e | ||
|
|
1a2f5308a0 | ||
|
|
cd04593d04 | ||
|
|
38241e7fae | ||
|
|
2e6d6a05b9 | ||
|
|
c73d858895 | ||
|
|
6e5486586c | ||
|
|
93642b3741 | ||
|
|
b06f2e27ea | ||
|
|
86f69b7fca | ||
|
|
fefa135f5a | ||
|
|
8fe2d35d62 | ||
|
|
21046c3047 | ||
|
|
017561a20b | ||
|
|
d3a68b2754 | ||
|
|
e98a560523 | ||
|
|
6983e8ec2f | ||
|
|
dc355602a9 | ||
|
|
924fa9259d | ||
|
|
24deeb0dbb | ||
|
|
0ce7f6e430 | ||
|
|
84fd0d0a60 | ||
|
|
96c99e0396 | ||
|
|
fd4838fc36 | ||
|
|
4873905884 | ||
|
|
6b37788971 | ||
|
|
44bf648bee |
No files matched your search
@@ -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 +0,0 @@
|
||||
../../.agents/skills/eval
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -40,7 +40,6 @@ CLAUDE.local.md
|
||||
.claude/skills/*
|
||||
!.claude/skills/verify
|
||||
!.claude/skills/issue-fix
|
||||
!.claude/skills/eval
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -9,10 +9,10 @@
|
||||
"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.
|
||||
|
||||
|
||||
Створено
@@ -4618,6 +4618,7 @@ name = "ide-lsp"
|
||||
version = "0.1.42"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
//! - 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};
|
||||
@@ -57,6 +58,9 @@ 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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -93,9 +93,81 @@ 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"];
|
||||
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
|
||||
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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,7 +107,15 @@ 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())),
|
||||
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()))
|
||||
}),
|
||||
current: str_of(o, "currentValue"),
|
||||
values,
|
||||
via: AgentConfigVia::Option,
|
||||
@@ -333,6 +341,21 @@ 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": [
|
||||
|
||||
@@ -16,6 +16,7 @@ mod model;
|
||||
mod native;
|
||||
mod provider;
|
||||
pub mod registry;
|
||||
pub mod security;
|
||||
mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
@@ -39,6 +40,9 @@ 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.
|
||||
@@ -102,6 +106,7 @@ 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.
|
||||
@@ -177,9 +182,15 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok(found) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
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 {
|
||||
let commands =
|
||||
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": found.options }));
|
||||
@@ -198,6 +209,8 @@ 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);
|
||||
});
|
||||
@@ -220,12 +233,17 @@ impl Shared {
|
||||
fn knows_all_levels(&self, provider: &str) -> bool {
|
||||
let cache = self.known.lock();
|
||||
let Some(known) = cache.get(provider) else { return false };
|
||||
known
|
||||
let models: Vec<&str> = known
|
||||
.config
|
||||
.iter()
|
||||
.filter(|o| o.category.as_deref() == Some("model"))
|
||||
.flat_map(|o| &o.values)
|
||||
.all(|v| known.thought_levels.contains_key(&v.value))
|
||||
.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))
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
@@ -434,6 +452,7 @@ impl AgentManager {
|
||||
runtime,
|
||||
connector,
|
||||
sink: RwLock::new(None),
|
||||
security: RwLock::new(None),
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
@@ -477,6 +496,11 @@ 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());
|
||||
@@ -501,7 +525,9 @@ 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;
|
||||
*prev = specs.clone();
|
||||
drop(probes);
|
||||
drop(prev);
|
||||
list
|
||||
}
|
||||
|
||||
@@ -522,6 +548,30 @@ 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());
|
||||
@@ -552,10 +602,16 @@ 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| s.driver == provider::Driver::Claude)
|
||||
&& let Some(spec) = self
|
||||
.shared
|
||||
.spec(&provider)
|
||||
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
|
||||
{
|
||||
let claude = spec.driver == provider::Driver::Claude;
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
if claude {
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
@@ -888,14 +944,38 @@ 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") || target.current.as_deref() == Some(value.as_str());
|
||||
target.category.as_deref() != Some("model") || previous.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 =
|
||||
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
|
||||
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)));
|
||||
}
|
||||
};
|
||||
if let Some(options) = reply.get("configOptions") {
|
||||
config::merge_update(&mut task.state.lock().task.config, options);
|
||||
model_options = true;
|
||||
@@ -906,8 +986,6 @@ 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);
|
||||
}
|
||||
@@ -1260,15 +1338,19 @@ impl AgentManager {
|
||||
/// Files the task changed since it started.
|
||||
pub async fn changes(&self, id: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
let task = self.task(id)?;
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let at = task.base();
|
||||
let before = task.state.lock().before.clone();
|
||||
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
|
||||
blocking(move || at.changes(&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 (cwd, base) = self.task(id)?.cwd_and_base();
|
||||
blocking(move || worktree::file_at(&cwd, &path, &base)).await
|
||||
let at = self.task(id)?.base();
|
||||
blocking(move || match at.rev()? {
|
||||
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
|
||||
None => Ok(None),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
|
||||
@@ -1278,16 +1360,17 @@ impl AgentManager {
|
||||
if task.state.lock().busy {
|
||||
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
|
||||
}
|
||||
let (cwd, base) = task.cwd_and_base();
|
||||
let at = task.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 = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
|
||||
let changes = at.changes(&before)?;
|
||||
let rev = at.rev()?;
|
||||
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))) {
|
||||
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
|
||||
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)));
|
||||
@@ -1306,6 +1389,41 @@ 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() {
|
||||
@@ -1356,15 +1474,15 @@ impl Task {
|
||||
})
|
||||
}
|
||||
|
||||
fn cwd_and_base(&self) -> (PathBuf, String) {
|
||||
fn base(&self) -> worktree::TaskBase {
|
||||
let s = self.state.lock();
|
||||
// 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()))
|
||||
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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_task(&self) {
|
||||
@@ -2124,6 +2242,44 @@ 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 });
|
||||
@@ -2437,7 +2593,7 @@ fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
pub(crate) fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
|
||||
@@ -2496,6 +2652,16 @@ 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())
|
||||
}
|
||||
|
||||
@@ -602,3 +602,49 @@ 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,
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
|
||||
//! Registry, see `registry`).
|
||||
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -278,6 +278,127 @@ 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)]
|
||||
@@ -753,4 +874,80 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ 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>>>,
|
||||
@@ -157,6 +162,9 @@ 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"}));
|
||||
}
|
||||
@@ -190,6 +198,10 @@ 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())}));
|
||||
@@ -2631,6 +2643,92 @@ 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();
|
||||
@@ -2770,3 +2868,223 @@ 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,
|
||||
}
|
||||
|
||||
impl Checkout {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let checkout = Self { _tmp: tmp, dir };
|
||||
checkout.git(&["init", "-q", "-b", "main"]);
|
||||
checkout.git(&["config", "core.autocrlf", "false"]);
|
||||
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) {
|
||||
std::fs::write(self.dir.join(rel), text).unwrap();
|
||||
}
|
||||
|
||||
fn read(&self, rel: &str) -> String {
|
||||
std::fs::read_to_string(self.dir.join(rel)).unwrap()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let repo = Checkout::new();
|
||||
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"]);
|
||||
|
||||
let base = mgr.base_text(&task.id, repo.dir.join("a.txt")).await.unwrap();
|
||||
assert_eq!(base.as_deref(), Some("base\n"));
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n");
|
||||
assert_eq!(repo.read("mine.txt"), "my work\n", "the user's work survives the rollback");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
|
||||
let repo = Checkout::new();
|
||||
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"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "base\n");
|
||||
assert_eq!(repo.read("other.txt"), "feature\n");
|
||||
assert_eq!(repo.read("only-feature.txt"), "feature\n");
|
||||
assert!(!repo.dir.join("only-main.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
|
||||
let repo = Checkout::new();
|
||||
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"]);
|
||||
repo.write("a.txt", "agent\nagain\n");
|
||||
assert_eq!(changed(&mgr, &task.id).await, ["a.txt", "b.txt"]);
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert_eq!(repo.read("a.txt"), "agent\n", "back to the commit, not to before it");
|
||||
assert!(!repo.dir.join("b.txt").exists());
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_changes() {
|
||||
let repo = Checkout::new();
|
||||
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"]);
|
||||
assert_eq!(mgr.base_text(&task.id, repo.dir.join("new.txt")).await.unwrap(), None);
|
||||
|
||||
mgr.rollback_changes(&task.id, None).await.unwrap();
|
||||
assert!(!repo.dir.join("new.txt").exists());
|
||||
assert_eq!(repo.read("mine.txt"), "mine\n");
|
||||
assert!(changed(&mgr, &task.id).await.is_empty());
|
||||
}
|
||||
|
||||
#[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:?}"
|
||||
);
|
||||
}
|
||||
@@ -117,14 +117,136 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
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) {
|
||||
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.push(added(s.path, s.rel_path));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Ok(listed
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
before.contains_key(&c.rel_path)
|
||||
|| !repo.file_bytes_at(&c.path.0, Some(start)).is_ok_and(|at| at == std::fs::read(&c.path.0).ok())
|
||||
})
|
||||
.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)?;
|
||||
let mut accepted = Vec::with_capacity(changes.len());
|
||||
let mut skipped = Vec::new();
|
||||
for change in changes {
|
||||
let local = root.join(&change.rel_path);
|
||||
let base_bytes = repo.file_bytes_at(&change.path.0, Some(base))?;
|
||||
let current = std::fs::read(&change.path.0).ok();
|
||||
let local_bytes = std::fs::read(&local).ok();
|
||||
if local_bytes == current {
|
||||
accepted.push(change.rel_path.clone());
|
||||
} else if local_bytes == base_bytes {
|
||||
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()),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
} else {
|
||||
skipped.push(crate::AgentAcceptSkipped {
|
||||
path: change.rel_path.clone(),
|
||||
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(crate::AgentAcceptOutcome { accepted, skipped })
|
||||
}
|
||||
|
||||
/// Size and modification time of a file, `None` once it is gone.
|
||||
pub(crate) type Stamp = Option<(u64, u128)>;
|
||||
|
||||
@@ -176,11 +298,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 `base` (removed when `base`
|
||||
/// lacks it).
|
||||
/// 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).
|
||||
pub(crate) fn restore(
|
||||
cwd: &Path,
|
||||
base: &str,
|
||||
rev: Option<&str>,
|
||||
change: &AgentChange,
|
||||
before: &BTreeMap<String, Stamp>,
|
||||
saved: Option<&Path>,
|
||||
@@ -198,10 +320,13 @@ pub(crate) fn restore(
|
||||
})?;
|
||||
Some(std::fs::read(copy)?)
|
||||
}
|
||||
None => {
|
||||
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
|
||||
repo.file_bytes_at(path, Some(base))?
|
||||
}
|
||||
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,
|
||||
},
|
||||
};
|
||||
match content {
|
||||
Some(bytes) => {
|
||||
@@ -406,4 +531,94 @@ 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_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");
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,11 @@ 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"));
|
||||
@@ -278,6 +283,9 @@ 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();
|
||||
@@ -1041,6 +1049,22 @@ 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();
|
||||
@@ -1057,7 +1081,10 @@ mod tests {
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "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,
|
||||
@@ -1065,6 +1092,7 @@ mod tests {
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"core",
|
||||
"-am",
|
||||
@@ -1353,6 +1381,7 @@ mod tests {
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
@@ -1369,6 +1398,7 @@ mod tests {
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-Djacoco.skip=true",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
|
||||
@@ -536,6 +536,7 @@ mod tests {
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"-Djacoco.skip=true",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -275,6 +275,11 @@ 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,
|
||||
},
|
||||
@@ -330,4 +335,12 @@ 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"]})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,16 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
|
||||
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
|
||||
pub fn to_ipc(p: &Path) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
dunce::simplified(p).to_string_lossy().replace('\\', "/")
|
||||
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
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
@@ -144,5 +149,8 @@ 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");
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ 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>>,
|
||||
@@ -411,6 +413,7 @@ impl Default for Settings {
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
mcp_server: McpServerSettings::default(),
|
||||
agent_security: AgentSecuritySettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +138,11 @@ 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.contains(&edit) {
|
||||
if !own[..merged].contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
@@ -277,10 +280,40 @@ pub struct OutlineItem {
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(test)]
|
||||
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();
|
||||
|
||||
@@ -412,3 +412,30 @@ 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(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,13 @@ 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" },
|
||||
|
||||
@@ -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];
|
||||
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
|
||||
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -9,11 +9,15 @@ use std::{
|
||||
|
||||
pub const ADAPTER: &str = "kotlin-debug-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 }
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
pub struct SourceMirror {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
pub mod kotlin_lsp;
|
||||
mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
@@ -610,9 +611,19 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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.
|
||||
@@ -621,14 +632,10 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
// 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()))
|
||||
});
|
||||
// `__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()));
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
@@ -981,6 +988,7 @@ 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(
|
||||
@@ -988,7 +996,7 @@ impl DebugManager {
|
||||
json!({
|
||||
"clientID": "openidea",
|
||||
"clientName": "OpenIDEA",
|
||||
"adapterID": s.adapter,
|
||||
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
|
||||
"locale": "en",
|
||||
"linesStartAt1": true,
|
||||
"columnsStartAt1": true,
|
||||
@@ -998,7 +1006,8 @@ impl DebugManager {
|
||||
"supportsRunInTerminalRequest": true,
|
||||
"supportsStartDebuggingRequest": true,
|
||||
"supportsProgressReporting": true,
|
||||
"supportsInvalidatedEvent": false,
|
||||
// kotlin-lsp sends `invalidated` either way.
|
||||
"supportsInvalidatedEvent": is_kotlin_lsp,
|
||||
"supportsMemoryReferences": false,
|
||||
"supportsArgsCanBeInterpretedByShell": false,
|
||||
}),
|
||||
@@ -1008,7 +1017,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 s.adapter == kotlin::ADAPTER && s.request == "launch" {
|
||||
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
|
||||
session::start_kotlin_debuggee(s, &mut arguments).await?;
|
||||
"attach"
|
||||
} else {
|
||||
@@ -1032,7 +1041,7 @@ impl DebugManager {
|
||||
jvm_memory::find_attached_jvm(s, port, finder).await;
|
||||
}
|
||||
}
|
||||
if s.adapter == kotlin::ADAPTER {
|
||||
if kotlin::is_kotlin(&s.adapter) {
|
||||
let host = arguments
|
||||
.get("hostName")
|
||||
.and_then(Value::as_str)
|
||||
@@ -1044,7 +1053,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()).await?;
|
||||
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).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);
|
||||
@@ -1381,6 +1390,8 @@ 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")
|
||||
@@ -1394,11 +1405,15 @@ 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(
|
||||
@@ -1407,11 +1422,15 @@ 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())
|
||||
@@ -1523,6 +1542,10 @@ 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.
|
||||
@@ -1567,8 +1590,13 @@ 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();
|
||||
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
|
||||
step_settled(&session, sent)?;
|
||||
// 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 });
|
||||
@@ -1587,6 +1615,8 @@ 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?;
|
||||
}
|
||||
@@ -1661,6 +1691,7 @@ 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
|
||||
}
|
||||
@@ -1668,11 +1699,16 @@ 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();
|
||||
session
|
||||
let sent = session
|
||||
.conn
|
||||
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
|
||||
.await?;
|
||||
.await;
|
||||
step_settled(&session, sent)?;
|
||||
session.suspended.resumed(ticket, thread_id, false);
|
||||
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
|
||||
Ok(())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
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)]
|
||||
@@ -103,6 +104,8 @@ 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
|
||||
@@ -252,6 +255,19 @@ 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(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +299,11 @@ 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);
|
||||
}
|
||||
@@ -697,7 +718,11 @@ 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 reason = str_("reason").unwrap_or_else(|| "pause".into());
|
||||
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);
|
||||
}
|
||||
mgr.restore_breakpoints(s).await;
|
||||
s.emit(DebugEvent::Stopped {
|
||||
thread_id: int("threadId").unwrap_or(0),
|
||||
@@ -799,6 +824,14 @@ 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +126,22 @@ 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,
|
||||
@@ -156,7 +170,14 @@ 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,
|
||||
@@ -202,6 +223,7 @@ 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) => {{
|
||||
@@ -222,6 +244,9 @@ 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 {
|
||||
@@ -320,9 +345,18 @@ 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);
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
|
||||
let reason = script.step_reason.unwrap_or("step");
|
||||
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
|
||||
}
|
||||
"setVariable" => {
|
||||
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
|
||||
@@ -358,6 +392,12 @@ 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}}));
|
||||
}
|
||||
@@ -365,11 +405,19 @@ 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": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
|
||||
{"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": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
|
||||
]})
|
||||
),
|
||||
@@ -393,6 +441,12 @@ 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}}));
|
||||
@@ -1418,6 +1472,7 @@ 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,
|
||||
@@ -2041,20 +2096,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_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
for name in ["__debug_bin", "__debug_bin833767175", "__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", "main.go"] {
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "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_bin.exe", "__debug_bin.exe3323301925"] {
|
||||
for name in ["__debug_bin", "__debug_bin833767175", "__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", "main.go", "__debug_bin.dir"] {
|
||||
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
|
||||
assert!(dir.join(name).exists(), "{name} should stay");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
@@ -2401,6 +2456,13 @@ 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());
|
||||
@@ -2472,6 +2534,229 @@ 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() {
|
||||
|
||||
@@ -9,6 +9,14 @@ 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
|
||||
@@ -17,6 +25,203 @@ 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`.
|
||||
fn custom_entries(root: &Path) -> Vec<PathBuf> {
|
||||
let mut entries = local_entries(root);
|
||||
for extra in ["logs", "agents/tools"] {
|
||||
let entry = PathBuf::from(extra);
|
||||
if !entries.contains(&entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
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());
|
||||
@@ -25,10 +230,14 @@ 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!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
|
||||
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
@@ -77,6 +286,11 @@ 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",
|
||||
@@ -118,31 +332,7 @@ fn migrate_entries(data: &Path, local: &Path) {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Returns true when a cross-volume copy is needed.
|
||||
@@ -597,4 +787,108 @@ 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_agent_tools() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entries = custom_entries(tmp.path());
|
||||
for expected in ["logs", "agents/tools", "index", "cache", "lsp", "agents/registry.json"] {
|
||||
assert!(entries.contains(&PathBuf::from(expected)), "{entries:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -44,6 +45,24 @@ 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)
|
||||
@@ -297,6 +316,18 @@ 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>>) {
|
||||
@@ -449,6 +480,9 @@ 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> {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
mod kotlin;
|
||||
#[cfg(test)]
|
||||
mod kotlin_tests;
|
||||
mod output;
|
||||
|
||||
use crate::Host;
|
||||
@@ -41,6 +44,7 @@ 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();
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! 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(¬ices, &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())
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! 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());
|
||||
}
|
||||
@@ -169,6 +169,25 @@ 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
|
||||
}
|
||||
@@ -197,6 +216,17 @@ 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
|
||||
});
|
||||
|
||||
@@ -19,7 +19,17 @@ 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)).await
|
||||
blocking(move || {
|
||||
project.documents.open_virtual(
|
||||
&vd.uri,
|
||||
vd.language_id,
|
||||
&vd.text,
|
||||
&vd.encoding,
|
||||
vd.has_bom,
|
||||
vd.can_reload_encoding,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
|
||||
@@ -30,6 +40,8 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
|
||||
}
|
||||
|
||||
/// "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.
|
||||
pub async fn doc_set_encoding(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -38,6 +50,14 @@ pub async fn doc_set_encoding(
|
||||
reload: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -176,8 +176,11 @@ 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").unwrap();
|
||||
let virtual_doc = f
|
||||
.project
|
||||
.documents
|
||||
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
|
||||
.unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
|
||||
@@ -544,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).await;
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri, None).await;
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
@@ -231,8 +231,16 @@ 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
|
||||
// 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) {
|
||||
// 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) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
|
||||
|
||||
@@ -20,10 +20,13 @@ 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 {
|
||||
@@ -47,7 +50,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 = vcs_file_at(host, window, path, Some(target.revision.clone()))
|
||||
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
|
||||
.await?
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
|
||||
Ok(ide_lsp::types::VirtualDocument {
|
||||
@@ -57,8 +60,63 @@ 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();
|
||||
|
||||
@@ -896,6 +896,13 @@ 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();
|
||||
|
||||
@@ -1381,6 +1381,17 @@ 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)
|
||||
|
||||
@@ -121,9 +121,14 @@ 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)
|
||||
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)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
@@ -280,12 +285,20 @@ impl PluginHost {
|
||||
.chain(&edges.references)
|
||||
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation anchors"));
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{}: invalid navigation anchors in {}",
|
||||
plugin.id(),
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if edges.markers.len() > MAX_EDGES
|
||||
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation markers"));
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{}: invalid navigation markers in {}",
|
||||
plugin.id(),
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut index = self.navigation.lock();
|
||||
if index.epochs.get(root).copied() != generation {
|
||||
@@ -406,7 +419,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 {
|
||||
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
|
||||
if targets.contains(&Location::file(path.clone(), candidate.range))
|
||||
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
|
||||
{
|
||||
@@ -414,7 +427,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
let mut matches = HashMap::new();
|
||||
for candidate in &file.edges.references {
|
||||
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
|
||||
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))
|
||||
@@ -580,3 +593,21 @@ 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))));
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::PromptWaiting { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
| PluginEvent::EndpointsChanged { .. }
|
||||
|
||||
@@ -321,6 +321,10 @@ 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 },
|
||||
|
||||
@@ -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};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -50,6 +50,8 @@ 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.
|
||||
@@ -563,6 +565,7 @@ 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,
|
||||
@@ -596,6 +599,12 @@ 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 {
|
||||
@@ -718,6 +727,7 @@ 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,
|
||||
@@ -1221,7 +1231,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 Settings › Project Structure › Dependencies —
|
||||
/// only on the roots picked per member in 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>)>;
|
||||
|
||||
@@ -93,9 +93,15 @@ 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 document =
|
||||
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
|
||||
let uri = format!("git-revision:{hex}/deleted.txt");
|
||||
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).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());
|
||||
|
||||
@@ -4157,6 +4157,35 @@ 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 [
|
||||
|
||||
@@ -330,6 +330,7 @@ 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",
|
||||
|
||||
@@ -94,6 +94,7 @@ 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",
|
||||
|
||||
@@ -105,6 +105,89 @@ 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);
|
||||
@@ -221,6 +304,44 @@ 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(
|
||||
|
||||
@@ -55,6 +55,10 @@ 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)]
|
||||
@@ -90,6 +94,18 @@ 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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ 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")),
|
||||
@@ -371,14 +372,13 @@ impl LanguageRegistry {
|
||||
.map(|f| f.ui_mode.clone())
|
||||
}
|
||||
|
||||
/// Creating or removing `path` can change a file's `framework_ui_mode`:
|
||||
/// it is a framework's root marker.
|
||||
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
|
||||
/// language (`framework-files`): it is a framework's root marker.
|
||||
pub fn is_framework_marker(&self, path: &Path) -> bool {
|
||||
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)))
|
||||
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))
|
||||
}
|
||||
|
||||
/// Detects by Override File Type, exact file name, then file-name pattern
|
||||
@@ -400,6 +400,9 @@ 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('.') {
|
||||
@@ -420,6 +423,19 @@ 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();
|
||||
@@ -497,6 +513,7 @@ mod tests {
|
||||
"Brainfuck",
|
||||
"Cobol",
|
||||
"C#",
|
||||
"Django Template",
|
||||
"Clojure",
|
||||
"ClojureScript",
|
||||
"Closure Stylesheets (GSS)",
|
||||
@@ -801,6 +818,36 @@ 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();
|
||||
|
||||
@@ -29,4 +29,5 @@ zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
|
||||
@@ -0,0 +1,223 @@
|
||||
//! go.mod as GoLand shows it: available upgrades, vulnerabilities and deprecated modules are
|
||||
//! reported as soon as the file is opened. gopls reports upgrades only from its "Check for
|
||||
//! upgrades" code lens, so that runs once per go.mod and session; its results (and the
|
||||
//! vulnerabilities from the `vulncheck` setting) come back as gopls diagnostics with quick fixes.
|
||||
//! gopls never reports a deprecated module, so `go list -m -u` does, on open and on save.
|
||||
|
||||
use crate::LspManager;
|
||||
use crate::server::Server;
|
||||
use ide_core::{DocId, Pos, Range};
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const CHECK_UPGRADES: &str = "gopls.check_upgrades";
|
||||
/// gopls answers code lenses once it has loaded the module.
|
||||
const ATTEMPTS: u32 = 10;
|
||||
const RETRY: Duration = Duration::from_secs(2);
|
||||
/// Producer of the deprecation diagnostics (one full set per go.mod).
|
||||
const DEPRECATIONS: &str = "go-mod-deprecations";
|
||||
|
||||
fn is_go_mod(path: &Path) -> bool {
|
||||
path.file_name().is_some_and(|n| n == "go.mod")
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) async fn check_go_mod(&self, doc: DocId, path: &Path, server: Arc<Server>) {
|
||||
if server.name != "gopls" || !is_go_mod(path) {
|
||||
return;
|
||||
}
|
||||
self.report_deprecated_modules(doc, &server).await;
|
||||
if !self.go_mod_checked.lock().insert(path.to_owned()) {
|
||||
return;
|
||||
}
|
||||
for attempt in 0..ATTEMPTS {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
if !self.docs.lock().contains_key(&doc) {
|
||||
break;
|
||||
}
|
||||
let Ok(lenses) = self.code_lens(doc).await else { continue };
|
||||
let Some(command) = lenses.into_iter().filter_map(|l| l.command).find(|c| c.command == CHECK_UPGRADES)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.execute_command(doc, command).await {
|
||||
tracing::debug!("gopls: {CHECK_UPGRADES} for {}: {}", path.display(), e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not checked after all (closed, or gopls never offered the lens): a later open retries.
|
||||
self.go_mod_checked.lock().remove(path);
|
||||
}
|
||||
|
||||
/// After a save of a go.mod: its requirements may have changed.
|
||||
pub(crate) fn go_mod_saved(&self, doc: DocId) {
|
||||
let server = {
|
||||
let docs = self.docs.lock();
|
||||
let Some(d) = docs.get(&doc).filter(|d| is_go_mod(&d.path)) else { return };
|
||||
let Some(a) = d.servers.iter().find(|a| a.server.name == "gopls") else { return };
|
||||
a.server.clone()
|
||||
};
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
self.runtime.spawn(async move { me.report_deprecated_modules(doc, &server).await });
|
||||
}
|
||||
|
||||
async fn report_deprecated_modules(&self, doc: DocId, server: &Server) {
|
||||
let Some((path, text)) = self.docs.lock().get(&doc).map(|d| (d.path.clone(), d.text.as_str().to_owned()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let required = required_modules(&text);
|
||||
if required.is_empty() {
|
||||
return;
|
||||
}
|
||||
let config = server.effective_config();
|
||||
let search = crate::launch::server_search_path(&config);
|
||||
let Some(dir) = path.parent().map(Path::to_path_buf) else { return };
|
||||
let modules: Vec<String> = required.iter().map(|r| r.path.clone()).collect();
|
||||
let listed = tokio::task::spawn_blocking(move || list_deprecations(&dir, &search, &modules)).await;
|
||||
let Ok(Some(deprecated)) = listed else { return };
|
||||
// The document changed meanwhile: its own save runs again.
|
||||
if self.docs.lock().get(&doc).is_none_or(|d| d.text.as_str() != text) {
|
||||
return;
|
||||
}
|
||||
let items = required
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let message = deprecated.iter().find(|(p, _)| *p == r.path)?.1.trim();
|
||||
Some(ide_core::Diagnostic {
|
||||
range: r.range,
|
||||
severity: ide_core::Severity::Warning,
|
||||
message: format!("{} is deprecated: {message}", r.path),
|
||||
source: Some("go".into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: true,
|
||||
server: Some("gopls".into()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.sink().emit(ide_core::IdeEvent::Diagnostics { path: path.into(), source: DEPRECATIONS.into(), items });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Required {
|
||||
path: String,
|
||||
/// The module path in go.mod (UTF-16 columns).
|
||||
range: Range,
|
||||
}
|
||||
|
||||
/// Modules of go.mod's `require` directives, single-line or in a block.
|
||||
fn required_modules(text: &str) -> Vec<Required> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_block = false;
|
||||
for (line, raw) in text.lines().enumerate() {
|
||||
let code = raw.split("//").next().unwrap_or("");
|
||||
let trimmed = code.trim_start();
|
||||
let entry = if in_block {
|
||||
if trimmed.starts_with(')') {
|
||||
in_block = false;
|
||||
continue;
|
||||
}
|
||||
trimmed
|
||||
} else if let Some(rest) = trimmed.strip_prefix("require").filter(|r| r.starts_with(char::is_whitespace)) {
|
||||
let rest = rest.trim_start();
|
||||
if rest.starts_with('(') {
|
||||
in_block = true;
|
||||
continue;
|
||||
}
|
||||
rest
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut words = entry.split_whitespace();
|
||||
let (Some(module), Some(_version)) = (words.next(), words.next()) else { continue };
|
||||
let module = module.trim_matches('"');
|
||||
let Some(start) = code.find(module) else { continue };
|
||||
let col = |byte: usize| code[..byte].encode_utf16().count() as u32;
|
||||
let line = line as u32;
|
||||
out.push(Required {
|
||||
path: module.to_owned(),
|
||||
range: Range { start: Pos { line, col: col(start) }, end: Pos { line, col: col(start + module.len()) } },
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListedModule {
|
||||
#[serde(rename = "Path")]
|
||||
path: String,
|
||||
#[serde(rename = "Deprecated", default)]
|
||||
deprecated: Option<String>,
|
||||
}
|
||||
|
||||
/// `(module, message)` of each deprecated module in `modules`; `None` when `go` failed.
|
||||
fn list_deprecations(dir: &Path, search: &std::ffi::OsStr, modules: &[String]) -> Option<Vec<(String, String)>> {
|
||||
let go: PathBuf = ide_core::tool_path::find_command("go", search, dir)?;
|
||||
let output = ide_core::process::prepare(&mut std::process::Command::new(go))
|
||||
.args(["list", "-m", "-u", "-e", "-json"])
|
||||
.args(modules)
|
||||
.current_dir(dir)
|
||||
.env("PATH", search)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.stdout.is_empty() {
|
||||
tracing::debug!("go list -m -u in {}: {}", dir.display(), output.status);
|
||||
return None;
|
||||
}
|
||||
Some(parse_deprecations(&output.stdout))
|
||||
}
|
||||
|
||||
fn parse_deprecations(json: &[u8]) -> Vec<(String, String)> {
|
||||
serde_json::Deserializer::from_slice(json)
|
||||
.into_iter::<ListedModule>()
|
||||
.map_while(Result::ok)
|
||||
.filter_map(|m| Some((m.path, m.deprecated.filter(|d| !d.trim().is_empty())?)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(line: u32, from: u32, to: u32) -> Range {
|
||||
Range { start: Pos { line, col: from }, end: Pos { line, col: to } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_required_modules_in_blocks_and_single_lines() {
|
||||
let text = "module example.com/m\n\ngo 1.24\n\nrequire github.com/a/b v1.0.0 // indirect\n\
|
||||
require (\n\tgithub.com/c/d v1.2.0\n\t\"github.com/e/é\" v0.1.0 // indirect\n)\n\
|
||||
// require github.com/x/y v1.0.0\nreplace github.com/c/d => ../d\ntool golang.org/x/tools/cmd/stringer\n";
|
||||
assert_eq!(
|
||||
required_modules(text),
|
||||
vec![
|
||||
Required { path: "github.com/a/b".into(), range: at(4, 8, 22) },
|
||||
Required { path: "github.com/c/d".into(), range: at(6, 1, 15) },
|
||||
Required { path: "github.com/e/é".into(), range: at(7, 2, 16) },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_deprecations_from_the_go_list_stream() {
|
||||
let json = br#"{"Path": "github.com/golang/protobuf", "Version": "v1.5.4",
|
||||
"Deprecated": "Use the \"google.golang.org/protobuf\" module instead."}
|
||||
{"Path": "github.com/sergi/go-diff", "Version": "v1.3.1"}
|
||||
{"Path": "example.com/blank", "Deprecated": " "}"#;
|
||||
assert_eq!(
|
||||
parse_deprecations(json),
|
||||
vec![(
|
||||
"github.com/golang/protobuf".to_owned(),
|
||||
"Use the \"google.golang.org/protobuf\" module instead.".to_owned()
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,9 @@ impl Handler for ClientHandler {
|
||||
}
|
||||
}
|
||||
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
|
||||
"intellij/workspaceImportState" if self.server.name == crate::kotlin_lsp::KOTLIN_LSP => {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
pub(crate) const SERVER: &str = "jdtls";
|
||||
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! kotlin-lsp's built-in debug adapter: `start_debug_server` once the server has imported
|
||||
//! the project (breakpoints set before that are "verified" but never hit).
|
||||
|
||||
use crate::LspManager;
|
||||
use crate::server::Server;
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_core::{CancellationToken, LanguageId};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The server's name in `languages/kotlin.toml`.
|
||||
pub const KOTLIN_LSP: &str = "kotlin-lsp";
|
||||
/// A cold start imports the Gradle model before answering `initialize`.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// The last `intellij/workspaceImportState` of the current instance.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ImportState {
|
||||
pub finished: bool,
|
||||
/// Workspace folders whose import did not succeed.
|
||||
pub failed: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct KotlinDebugServer {
|
||||
/// Local TCP port of the DAP server.
|
||||
pub port: u16,
|
||||
/// Root of the kotlin-lsp instance that serves it.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
/// Why kotlin-lsp cannot debug the module, for the fallback notice.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum KotlinDebugUnavailable {
|
||||
/// No `kotlin-lsp` among the module's servers (disabled in settings, another server chosen).
|
||||
NotConfigured,
|
||||
NotInstalled,
|
||||
/// The status message, else the status.
|
||||
NotStarted(String),
|
||||
/// The import did not finish in time, or the wait was cancelled.
|
||||
StillImporting,
|
||||
ImportFailed,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Records `intellij/workspaceImportState` from instance `generation`. Completion is this
|
||||
/// notification, not "no `$/progress` left": kotlin-lsp reports "Indexing" every few seconds.
|
||||
pub(crate) fn record_import_state(server: &Server, generation: u64, params: &Value) {
|
||||
let failed = params["folders"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|folder| folder["status"] != "SUCCESS")
|
||||
.filter_map(|folder| folder["folderUri"].as_str().and_then(crate::uri::uri_to_path))
|
||||
.collect();
|
||||
server.set_import_state(generation, ImportState { finished: params["phase"] == "FINISHED", failed });
|
||||
}
|
||||
|
||||
fn importing(server: &Server) -> bool {
|
||||
server.progress.lock().values().any(|p| p.title.contains("Importing"))
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// Starts kotlin-lsp's debug server for `path` (a module directory or source file) once
|
||||
/// the project is imported, starting kotlin-lsp if needed. `import_wait` bounds the wait
|
||||
/// for the import after the server is up; cancelling `cancel` ends it as
|
||||
/// [`KotlinDebugUnavailable::StillImporting`].
|
||||
pub async fn kotlin_debug_server(
|
||||
&self,
|
||||
path: &Path,
|
||||
import_wait: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<KotlinDebugServer, KotlinDebugUnavailable> {
|
||||
let server = self
|
||||
.servers_for(&LanguageId::new("kotlin"), path)
|
||||
.into_iter()
|
||||
.find(|s| s.name == KOTLIN_LSP)
|
||||
.ok_or(KotlinDebugUnavailable::NotConfigured)?;
|
||||
self.ensure_started(&server);
|
||||
let ready = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
|
||||
ready = server.wait_ready(READY_TIMEOUT) => ready,
|
||||
};
|
||||
if ready.is_none() {
|
||||
let status = server.status();
|
||||
return Err(match status {
|
||||
ServerStatus::Missing => KotlinDebugUnavailable::NotInstalled,
|
||||
ServerStatus::Misconfigured | ServerStatus::Crashed => {
|
||||
KotlinDebugUnavailable::NotStarted(server.status_message().unwrap_or_else(|| format!("{status:?}")))
|
||||
}
|
||||
_ => KotlinDebugUnavailable::NotStarted(format!("{status:?}")),
|
||||
});
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
|
||||
imported = tokio::time::timeout(import_wait, wait_for_import(&server, path)) => {
|
||||
imported.unwrap_or(Err(KotlinDebugUnavailable::StillImporting))?
|
||||
}
|
||||
}
|
||||
// The instance that finished importing, also when it replaced the one waited for.
|
||||
let Some(run) = server.running() else {
|
||||
return Err(KotlinDebugUnavailable::NotStarted(format!("{:?}", server.status())));
|
||||
};
|
||||
let params =
|
||||
json!({ "command": "start_debug_server", "arguments": [crate::uri::path_to_uri_string(&server.root)] });
|
||||
let request = run.peer.request::<Value, Value>("workspace/executeCommand", params, Some(cancel.clone()));
|
||||
let port = match tokio::time::timeout(COMMAND_TIMEOUT, request).await {
|
||||
Ok(Ok(port)) => port,
|
||||
Ok(Err(e)) => return Err(KotlinDebugUnavailable::Failed(e.to_string())),
|
||||
Err(_) => {
|
||||
return Err(KotlinDebugUnavailable::Failed(format!(
|
||||
"start_debug_server timed out after {} s",
|
||||
COMMAND_TIMEOUT.as_secs()
|
||||
)));
|
||||
}
|
||||
};
|
||||
match port.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
|
||||
Some(port) => Ok(KotlinDebugServer { port, root: server.root.clone() }),
|
||||
None => Err(KotlinDebugUnavailable::Failed(format!("unexpected debug server port: {port}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until the current instance reports its import finished and its "Importing" task
|
||||
/// ended, which arrive in either order.
|
||||
async fn wait_for_import(server: &Server, path: &Path) -> Result<(), KotlinDebugUnavailable> {
|
||||
let mut state = server.import_state.subscribe();
|
||||
loop {
|
||||
// Created before the check so a progress end in between still wakes us.
|
||||
let progress_ended = server.phase_notified();
|
||||
let current = state.borrow_and_update().clone();
|
||||
if let Some(current) = current
|
||||
&& current.finished
|
||||
&& !importing(server)
|
||||
{
|
||||
if current.failed.iter().any(|dir| canonical(path).starts_with(canonical(dir))) {
|
||||
return Err(KotlinDebugUnavailable::ImportFailed);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
tokio::select! {
|
||||
_ = state.changed() => {}
|
||||
() = progress_ended => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with its deepest existing ancestor resolved, so a symlinked root (or `/tmp` vs
|
||||
/// `/private/tmp` on macOS) and the folder URIs kotlin-lsp reports name one directory alike.
|
||||
fn canonical(path: &Path) -> PathBuf {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Ok(real) = dunce::canonicalize(ancestor) {
|
||||
let rest = path.strip_prefix(ancestor).unwrap_or(Path::new(""));
|
||||
return if rest.as_os_str().is_empty() { real } else { real.join(rest) };
|
||||
}
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn an_instance_that_was_replaced_does_not_record_its_import() {
|
||||
let cfg = toml::from_str("name = \"kotlin-lsp\"\ncommand = \"kotlin-lsp\"").unwrap();
|
||||
let server = Server::new(cfg, PathBuf::from("/p"), LanguageId::new("kotlin"));
|
||||
let finished = json!({ "phase": "FINISHED", "folders": [
|
||||
{ "folderUri": "file:///p/", "status": "SUCCESS" },
|
||||
{ "folderUri": "file:///p/lib/", "status": "FAILED" },
|
||||
]});
|
||||
let old = server.begin_start().unwrap();
|
||||
record_import_state(&server, old, &json!({ "phase": "IN_PROGRESS", "folders": [] }));
|
||||
assert_eq!(*server.import_state.borrow(), Some(ImportState { finished: false, failed: vec![] }));
|
||||
record_import_state(&server, old, &finished);
|
||||
let done = Some(ImportState { finished: true, failed: vec![PathBuf::from("/p/lib")] });
|
||||
assert_eq!(*server.import_state.borrow(), done);
|
||||
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
server.begin_stop(&sink, false);
|
||||
let new = server.begin_start().unwrap();
|
||||
assert_eq!(*server.import_state.borrow(), None);
|
||||
// The dying instance's FINISHED arrives after the restart.
|
||||
record_import_state(&server, old, &finished);
|
||||
assert_eq!(*server.import_state.borrow(), None);
|
||||
record_import_state(&server, new, &finished);
|
||||
assert_eq!(*server.import_state.borrow(), done);
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
|
||||
}
|
||||
|
||||
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
|
||||
fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_mod;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod injections;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
mod kotlin_lsp;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
@@ -38,6 +40,7 @@ mod vue_navigation;
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use jdtls::jvm_inspect::JdtJvmInspector;
|
||||
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
|
||||
@@ -219,6 +222,8 @@ pub struct LspManager {
|
||||
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// go.mod files whose upgrades gopls has been asked to check this session.
|
||||
go_mod_checked: Mutex<HashSet<PathBuf>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
@@ -317,9 +322,48 @@ impl<T> Outcome<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a rename the server withdrew (`ContentModified`) is asked, and the pause between.
|
||||
const RENAME_ATTEMPTS: u32 = 5;
|
||||
const RENAME_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// Implementation requests of one line-marker pass in flight at once.
|
||||
const IMPLEMENTATION_CONCURRENCY: usize = 4;
|
||||
|
||||
/// How a virtual document's content was decoded, and whether it can be
|
||||
/// re-decoded ("Reload with Encoding").
|
||||
struct Decoding {
|
||||
encoding: String,
|
||||
has_bom: bool,
|
||||
can_reload: bool,
|
||||
}
|
||||
|
||||
impl Decoding {
|
||||
/// Text the server decoded itself: no raw bytes here to re-read.
|
||||
fn server_text() -> Self {
|
||||
Decoding { encoding: "utf-8".into(), has_bom: false, can_reload: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// jdtls' source attachment of a `jdt:` class (`java.project.resolveSourceAttachment`).
|
||||
struct SourceAttachment {
|
||||
path: Option<String>,
|
||||
encoding: Option<String>,
|
||||
can_edit_encoding: bool,
|
||||
}
|
||||
|
||||
/// Reads a `jar:` document's source: an entry as is, a class from the sources
|
||||
/// jar next to its binary. `encoding` decodes with it instead of detecting.
|
||||
fn read_jar_source(uri: &str, encoding: Option<&str>) -> Option<(String, ide_vfs::LoadedText)> {
|
||||
match library_source::parse(uri) {
|
||||
Some(e) if !e.entry.ends_with(".class") => {
|
||||
library_source::read_entry(&e.jar, &e.entry, encoding).map(|loaded| (e.entry, loaded))
|
||||
}
|
||||
Some(_) => library_source::class_source(uri, encoding)
|
||||
.and_then(|(source, loaded)| Some((library_source::parse(&source)?.entry, loaded))),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Library classes in `jar:` targets (the official Kotlin LSP) go to their source
|
||||
/// in the artifact's sources jar when it is cached, at the declaration of the
|
||||
/// name navigated from; [`LspManager::virtual_document`] serves it.
|
||||
@@ -334,8 +378,9 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
|
||||
let Some(uri) = loc.uri.as_deref().filter(|u| u.starts_with("jar:") && u.ends_with(".class")) else {
|
||||
return loc;
|
||||
};
|
||||
let Some((source, text)) = library_source::class_source(uri) else { return loc };
|
||||
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
|
||||
let Some((source, loaded)) = library_source::class_source(uri, None) else { return loc };
|
||||
let range =
|
||||
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
|
||||
Location { path: source.clone().into(), range, uri: Some(source) }
|
||||
})
|
||||
.collect()
|
||||
@@ -427,6 +472,7 @@ impl LspManager {
|
||||
debug_servers: Mutex::new(HashMap::new()),
|
||||
configuration_cache: Arc::new(Mutex::new(Default::default())),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
go_mod_checked: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
@@ -1026,6 +1072,7 @@ impl LspManager {
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let canonical_path = canonical.clone();
|
||||
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
|
||||
let mut state = DocState {
|
||||
path,
|
||||
@@ -1056,6 +1103,13 @@ impl LspManager {
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
for s in &servers {
|
||||
self.ensure_started(s);
|
||||
// Waits for a server still starting, so it is not tied to `running()` below.
|
||||
if s.name == "gopls"
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
let (path, server) = (canonical_path.clone(), s.clone());
|
||||
self.runtime.spawn(async move { me.check_go_mod(doc, &path, server).await });
|
||||
}
|
||||
if s.running().is_some()
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
@@ -1197,6 +1251,7 @@ impl LspManager {
|
||||
drop(docs);
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
self.pull_dependents(doc);
|
||||
self.go_mod_saved(doc);
|
||||
}
|
||||
|
||||
pub fn did_close(&self, doc: DocId) {
|
||||
@@ -2374,8 +2429,25 @@ impl LspManager {
|
||||
let query = || async {
|
||||
let mut params = snap.pos_params(t, pos);
|
||||
params["newName"] = json!(new_name);
|
||||
let r: Option<lsp::WorkspaceEdit> =
|
||||
self.call(t, "textDocument/rename", params, Some(cancel), LONG_TIMEOUT).await?;
|
||||
// A server still analysing an edit withdraws the rename (`ContentModified`, which
|
||||
// arrives as a cancellation). The user asked for it: ask again shortly instead of
|
||||
// dropping it without a word, and say why if it keeps being withdrawn.
|
||||
let mut attempt = 0;
|
||||
let r: Option<lsp::WorkspaceEdit> = loop {
|
||||
match self.call(t, "textDocument/rename", params.clone(), Some(cancel), LONG_TIMEOUT).await {
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled && !cancel.is_cancelled() => {
|
||||
attempt += 1;
|
||||
if attempt >= RENAME_ATTEMPTS {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} was still analysing a change and withdrew the rename; try again",
|
||||
t.server.name
|
||||
)));
|
||||
}
|
||||
tokio::time::sleep(RENAME_RETRY_DELAY).await;
|
||||
}
|
||||
r => break r?,
|
||||
}
|
||||
};
|
||||
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
|
||||
};
|
||||
// The Angular server renames in the file's own project only, whatever is open.
|
||||
@@ -2997,8 +3069,9 @@ impl LspManager {
|
||||
let original = uri.to_owned();
|
||||
let archive = sources.clone();
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
library_source::source_in_archive(&original, &archive).map(|(uri, text)| {
|
||||
let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default();
|
||||
library_source::source_in_archive(&original, &archive, None).map(|(uri, loaded)| {
|
||||
let range =
|
||||
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
|
||||
Location { path: uri.clone().into(), uri: Some(uri), range }
|
||||
})
|
||||
})
|
||||
@@ -3040,28 +3113,10 @@ impl LspManager {
|
||||
if scheme == "jar" {
|
||||
let found = {
|
||||
let uri = uri.to_owned();
|
||||
tokio::task::spawn_blocking(move || match library_source::parse(&uri) {
|
||||
Some(e) if !e.entry.ends_with(".class") => {
|
||||
library_source::read_entry(&e.jar, &e.entry).map(|text| (e.entry, text))
|
||||
}
|
||||
Some(_) => library_source::class_source(&uri)
|
||||
.and_then(|(source, text)| Some((library_source::parse(&source)?.entry, text))),
|
||||
None => None,
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
|
||||
};
|
||||
if let Some((entry, text)) = found {
|
||||
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
|
||||
return Ok(self.store_virtual(
|
||||
uri,
|
||||
text,
|
||||
Some(title),
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
));
|
||||
if let Some((entry, loaded)) = found {
|
||||
return Ok(self.store_jar_entry(uri, entry, loaded));
|
||||
}
|
||||
}
|
||||
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
|
||||
@@ -3132,7 +3187,21 @@ impl LspManager {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
|
||||
// jdtls decodes attached sources itself: its attachment encoding is
|
||||
// the document's real charset, and editable ones can be re-decoded.
|
||||
let decoding = if scheme == "jdt" && t.server.name == jdtls::SERVER && !virtual_doc::is_decompiled(&text) {
|
||||
match self.jdt_source_attachment(uri).await {
|
||||
Some(att) if att.path.is_some() => Decoding {
|
||||
encoding: att.encoding.unwrap_or_else(|| "utf-8".into()),
|
||||
has_bom: false,
|
||||
can_reload: att.can_edit_encoding,
|
||||
},
|
||||
_ => Decoding::server_text(),
|
||||
}
|
||||
} else {
|
||||
Decoding::server_text()
|
||||
};
|
||||
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id), decoding));
|
||||
}
|
||||
if jar_class && error.is_none() {
|
||||
return Err(IdeError::not_found(format!(
|
||||
@@ -3144,6 +3213,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Caches `text` as the virtual document `uri`, titled `title` (else after the URI).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn store_virtual(
|
||||
&self,
|
||||
uri: &str,
|
||||
@@ -3152,6 +3222,7 @@ impl LspManager {
|
||||
description: Option<String>,
|
||||
languages: &[LanguageId],
|
||||
server: Option<ide_core::ServerId>,
|
||||
decoding: Decoding,
|
||||
) -> VirtualDocument {
|
||||
let decompiled = if virtual_doc::scheme(uri) == "csharp" {
|
||||
virtual_doc::is_csharp_decompiled(uri)
|
||||
@@ -3172,6 +3243,9 @@ impl LspManager {
|
||||
title,
|
||||
description: description.or_else(|| virtual_doc::description(uri)),
|
||||
decompiled,
|
||||
encoding: decoding.encoding,
|
||||
has_bom: decoding.has_bom,
|
||||
can_reload_encoding: decoding.can_reload,
|
||||
};
|
||||
if let Some(server) = server {
|
||||
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
|
||||
@@ -3184,6 +3258,163 @@ impl LspManager {
|
||||
doc
|
||||
}
|
||||
|
||||
/// Caches a decoded `jar:` entry under the document's original URI, titled
|
||||
/// after the entry; the backend owns these bytes, so they can be re-decoded.
|
||||
fn store_jar_entry(&self, uri: &str, entry: String, loaded: ide_vfs::LoadedText) -> VirtualDocument {
|
||||
let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned();
|
||||
self.store_virtual(
|
||||
uri,
|
||||
loaded.text,
|
||||
Some(title),
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
|
||||
)
|
||||
}
|
||||
|
||||
/// The source attachment jdtls resolves for a `jdt:` class; `None` when the
|
||||
/// command is unavailable or the class's container cannot be queried.
|
||||
async fn jdt_source_attachment(&self, uri: &str) -> Option<SourceAttachment> {
|
||||
let result = self
|
||||
.jdtls_command(
|
||||
&self.project_root,
|
||||
"java.project.resolveSourceAttachment",
|
||||
vec![json!({ "classFileUri": uri })],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if result.get("errorMessage").and_then(Value::as_str).is_some_and(|s| !s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let attrs = result.get("attributes")?;
|
||||
let non_empty = |key: &str| attrs.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
Some(SourceAttachment {
|
||||
path: non_empty("sourceAttachmentPath"),
|
||||
encoding: non_empty("sourceAttachmentEncoding"),
|
||||
can_edit_encoding: attrs.get("canEditEncoding").and_then(Value::as_bool).unwrap_or(true),
|
||||
})
|
||||
}
|
||||
|
||||
/// Updates a `jdt:` class's source attachment encoding. The command drops
|
||||
/// the attachment when the path is omitted, so the resolved path is always
|
||||
/// sent along with the encoding.
|
||||
async fn update_jdt_source_encoding(&self, uri: &str, path: &str, encoding: &str) -> IdeResult<()> {
|
||||
let result = self
|
||||
.jdtls_command(
|
||||
&self.project_root,
|
||||
"java.project.updateSourceAttachment",
|
||||
vec![json!({
|
||||
"classFileUri": uri,
|
||||
"attributes": { "sourceAttachmentPath": path, "sourceAttachmentEncoding": encoding },
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) {
|
||||
return Err(IdeError::unavailable(error));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Waits until the resolved attachment reports `encoding`; `false` once
|
||||
/// `until` passes. Maven applies attachment updates in a background job,
|
||||
/// so the new encoding is not visible immediately after the update.
|
||||
async fn wait_for_jdt_source_encoding(&self, uri: &str, encoding: &str, until: tokio::time::Instant) -> bool {
|
||||
loop {
|
||||
match self.jdt_source_attachment(uri).await {
|
||||
Some(att) if att.encoding.as_deref() == Some(encoding) => return true,
|
||||
_ if tokio::time::Instant::now() < until => {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" for a virtual document: re-reads its origin with
|
||||
/// `encoding` and replaces the cached content. `jar:` entries are re-read
|
||||
/// here; `jdt:` sources are decoded by jdtls, so its source attachment
|
||||
/// encoding is updated and the contents fetched again (keeping the server's
|
||||
/// text and positions in sync). Server-generated content cannot be re-decoded.
|
||||
pub async fn reload_virtual(&self, uri: &str, encoding: &str) -> IdeResult<VirtualDocument> {
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
match virtual_doc::scheme(uri) {
|
||||
"jar" => {
|
||||
let (owned_uri, owned_encoding) = (uri.to_owned(), encoding.to_owned());
|
||||
let found = tokio::task::spawn_blocking(move || read_jar_source(&owned_uri, Some(&owned_encoding)))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let Some((entry, loaded)) = found else {
|
||||
return Err(IdeError::not_found(format!(
|
||||
"no source is available for {}",
|
||||
virtual_doc::title(uri, false)
|
||||
)));
|
||||
};
|
||||
Ok(self.store_jar_entry(uri, entry, loaded))
|
||||
}
|
||||
"jdt" if self.origin_server(uri).is_none_or(|s| s.name == jdtls::SERVER) => {
|
||||
let Some(att) = self.jdt_source_attachment(uri).await else {
|
||||
return Err(IdeError::unavailable("jdtls cannot resolve the source attachment of this class"));
|
||||
};
|
||||
let Some(path) = att.path else {
|
||||
return Err(IdeError::invalid("this class has no attached sources to re-decode"));
|
||||
};
|
||||
if !att.can_edit_encoding {
|
||||
return Err(IdeError::invalid("the source attachment encoding of this library cannot be changed"));
|
||||
}
|
||||
self.update_jdt_source_encoding(uri, &path, encoding).await?;
|
||||
// Maven containers apply the update in a background job (persist
|
||||
// attached sources, then refresh the classpath): wait until the
|
||||
// resolved attachment reports the new encoding, else the refetch
|
||||
// below would still decode with the old one.
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let first_try = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
if !self.wait_for_jdt_source_encoding(uri, encoding, first_try).await {
|
||||
// jdtls matches the existing encoding attribute by reference
|
||||
// (`==`), so an entry restored from the saved container state
|
||||
// gains a duplicate attribute and keeps the old value. That
|
||||
// update still refreshes the container, after which a second
|
||||
// update replaces the value correctly.
|
||||
self.update_jdt_source_encoding(uri, &path, encoding).await?;
|
||||
if !self.wait_for_jdt_source_encoding(uri, encoding, deadline).await {
|
||||
return Err(IdeError::unavailable(
|
||||
"jdtls did not apply the source attachment encoding; try reloading again",
|
||||
));
|
||||
}
|
||||
}
|
||||
// JDT keeps the class file's decoded buffer cached, so
|
||||
// classFileContents would still return the old decoding: re-read
|
||||
// the attached source entry here, like `jar:` documents. The
|
||||
// server's open document is kept in sync by the regular
|
||||
// document change flow.
|
||||
let (owned_uri, owned_path, owned_encoding) = (uri.to_owned(), path.clone(), encoding.to_owned());
|
||||
let found = tokio::task::spawn_blocking(move || {
|
||||
library_source::source_in_archive(&owned_uri, Path::new(&owned_path), Some(&owned_encoding))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if let Some((_source_uri, loaded)) = found {
|
||||
return Ok(self.store_virtual(
|
||||
uri,
|
||||
loaded.text,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
self.origin_server(uri).map(|s| s.id),
|
||||
Decoding { encoding: loaded.encoding, has_bom: loaded.has_bom, can_reload: true },
|
||||
));
|
||||
}
|
||||
// Attachments that are not readable archives (e.g. folders):
|
||||
// fall back to the server's contents.
|
||||
self.virtual_docs.lock().remove(uri);
|
||||
self.virtual_document(uri).await
|
||||
}
|
||||
_ => Err(IdeError::invalid(
|
||||
"this document's content is generated by a language server and cannot be re-decoded with another encoding",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
|
||||
/// so edits flow through `ide-workspace::Documents`.
|
||||
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! it in the Maven or Gradle cache.
|
||||
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_vfs::LoadedText;
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// An entry of an archive named by a `jar:` URI.
|
||||
@@ -57,29 +57,32 @@ impl JarEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of `entry` in the archive `jar`, when it is a UTF-8 file.
|
||||
pub(crate) fn read_entry(jar: &Path, entry: &str) -> Option<String> {
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?;
|
||||
let mut file = zip.by_name(entry).ok()?;
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
/// The text of `entry` in the archive `jar`, decoded like a file opened in the
|
||||
/// editor; `encoding` decodes with it instead of detecting ("Reload with
|
||||
/// Encoding"). `None` for binary or unreadable entries.
|
||||
pub(crate) fn read_entry(jar: &Path, entry: &str, encoding: Option<&str>) -> Option<LoadedText> {
|
||||
let path = ide_vfs::archive_entry_path(jar, entry);
|
||||
let loaded = match encoding {
|
||||
Some(enc) => ide_vfs::load_text_as(&path, enc).ok()?,
|
||||
None => ide_vfs::load_text(&path).ok()?,
|
||||
};
|
||||
(!loaded.binary).then_some(loaded)
|
||||
}
|
||||
|
||||
/// The library source for a class entry: its `.kt` / `.java` file in the
|
||||
/// artifact's sources jar, as `(uri, text)`.
|
||||
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
|
||||
pub(crate) fn class_source(uri: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
|
||||
let jar = parse(uri)?;
|
||||
let class = jar.entry.strip_suffix(".class")?;
|
||||
for sources in sources_jars(&jar.jar) {
|
||||
if let Some((entry, text)) = source_entry(&sources, class) {
|
||||
if let Some((entry, text)) = source_entry(&sources, class, encoding) {
|
||||
return Some((jar.uri_of(&sources, &entry), text));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
|
||||
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
|
||||
let jar = if uri.starts_with("jdt://contents/") {
|
||||
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
@@ -96,7 +99,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, St
|
||||
parse(uri)?
|
||||
};
|
||||
let class = jar.entry.strip_suffix(".class")?;
|
||||
let (entry, text) = source_entry(sources, class)?;
|
||||
let (entry, text) = source_entry(sources, class, encoding)?;
|
||||
Some((jar.uri_of(sources, &entry), text))
|
||||
}
|
||||
|
||||
@@ -117,8 +120,8 @@ fn sources_jars(jar: &Path) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// The source file of `class` (`pkg/Name`, `pkg/Outer$Inner`, `pkg/FileKt`) in `sources`.
|
||||
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
|
||||
fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(String, LoadedText)> {
|
||||
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
|
||||
let (package, name) = class.rsplit_once('/').unwrap_or(("", class));
|
||||
let outer = name.split('$').next().unwrap_or(name);
|
||||
let dir = if package.is_empty() { String::new() } else { format!("{package}/") };
|
||||
@@ -127,13 +130,9 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
|
||||
candidates.push(format!("{dir}{file}.kt"));
|
||||
}
|
||||
let read = |zip: &mut zip::ZipArchive<std::fs::File>, entry: &str| {
|
||||
let mut text = String::new();
|
||||
zip.by_name(entry).ok()?.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
};
|
||||
let read = |entry: &str| read_entry(sources, entry, encoding);
|
||||
for entry in &candidates {
|
||||
if let Some(text) = read(&mut zip, entry) {
|
||||
if let Some(text) = read(entry) {
|
||||
return Some((entry.clone(), text));
|
||||
}
|
||||
}
|
||||
@@ -145,8 +144,8 @@ fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
|
||||
.collect();
|
||||
let jvm_name = format!("@file:JvmName(\"{outer}\")");
|
||||
for entry in kotlin_files {
|
||||
if let Some(text) = read(&mut zip, &entry)
|
||||
&& text.contains(&jvm_name)
|
||||
if let Some(text) = read(&entry)
|
||||
&& text.text.contains(&jvm_name)
|
||||
{
|
||||
return Some((entry, text));
|
||||
}
|
||||
@@ -233,11 +232,15 @@ mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
fn jar(path: &Path, entries: &[(&str, &str)]) {
|
||||
jar_bytes(path, &entries.iter().map(|(n, t)| (*n, t.as_bytes())).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
fn jar_bytes(path: &Path, entries: &[(&str, &[u8])]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, text) in entries {
|
||||
for (name, bytes) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
@@ -274,12 +277,12 @@ mod tests {
|
||||
"jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path",
|
||||
"jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class",
|
||||
] {
|
||||
let (uri, text) = source_in_archive(uri, &sources).unwrap();
|
||||
let (uri, text) = source_in_archive(uri, &sources, None).unwrap();
|
||||
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
|
||||
assert!(text.contains("class Lib"));
|
||||
assert!(text.text.contains("class Lib"));
|
||||
}
|
||||
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none());
|
||||
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none());
|
||||
assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources, None).is_none());
|
||||
assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -312,20 +315,103 @@ mod tests {
|
||||
("org/springframework/boot/Builder.class", "Builder.kt"),
|
||||
("Root.class", "Root.java"),
|
||||
] {
|
||||
let (source_uri, text) = class_source(&uri(class)).unwrap_or_else(|| panic!("{class} in {binary:?}"));
|
||||
let (source_uri, text) =
|
||||
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
|
||||
let dir = class.rsplit_once('/').map_or(String::new(), |(p, _)| format!("{p}/"));
|
||||
assert!(source_uri.ends_with(&format!("-sources.jar!/{dir}{file}")), "{source_uri}");
|
||||
assert_eq!(
|
||||
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
|
||||
Some(text.as_str())
|
||||
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry, None)).map(|l| l.text),
|
||||
Some(text.text.clone())
|
||||
);
|
||||
}
|
||||
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
|
||||
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
|
||||
}
|
||||
// No sources jar: nothing to show from here.
|
||||
let lone = tmp.path().join("lone/x-1.jar");
|
||||
jar(&lone, &[]);
|
||||
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
|
||||
assert!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/")), None).is_none());
|
||||
}
|
||||
|
||||
/// The reported case: a library source file saved as GBK bytes, not UTF-8.
|
||||
const GBK_SAMPLE: &str = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {\n public static String message() {\n return \"中文编码测试:是否启用\";\n }\n}\n";
|
||||
|
||||
#[test]
|
||||
fn archive_entries_decode_like_files_opened_in_the_editor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
|
||||
// encoding_rs' encode() falls back to UTF-8 for UTF-16 (WHATWG has no encoder for it).
|
||||
let mut utf16 = vec![0xFF, 0xFE];
|
||||
utf16.extend(GBK_SAMPLE.encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let cases: Vec<(&str, Vec<u8>, &str, &str, bool)> = vec![
|
||||
("utf8.java", GBK_SAMPLE.as_bytes().to_vec(), GBK_SAMPLE, "utf-8", false),
|
||||
("bom.java", [b"\xEF\xBB\xBF", GBK_SAMPLE.as_bytes()].concat(), GBK_SAMPLE, "utf-8", true),
|
||||
("gbk.java", gbk, GBK_SAMPLE, "gbk", false),
|
||||
("utf16.java", utf16, GBK_SAMPLE, "utf-16le", true),
|
||||
];
|
||||
for (name, bytes, text, encoding, has_bom) in cases {
|
||||
let jar_path = tmp.path().join(format!("lib-{name}.jar"));
|
||||
jar_bytes(&jar_path, &[(&format!("p/{name}"), &bytes)]);
|
||||
let loaded = read_entry(&jar_path, &format!("p/{name}"), None).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(loaded.text, text, "{name}");
|
||||
assert_eq!(loaded.encoding, encoding, "{name}");
|
||||
assert_eq!(loaded.has_bom, has_bom, "{name}");
|
||||
assert!(!loaded.binary, "{name}");
|
||||
}
|
||||
// Binary entries are not shown as text.
|
||||
let jar_path = tmp.path().join("bin.jar");
|
||||
jar_bytes(&jar_path, &[("p/img.png", &[0x89, b'P', b'N', b'G', 0, 0, 0, 0])]);
|
||||
assert!(read_entry(&jar_path, "p/img.png", None).is_none());
|
||||
assert!(read_entry(&jar_path, "p/missing.java", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_decodes_archive_entries_with_the_chosen_encoding() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let jar_path = tmp.path().join("lib.jar");
|
||||
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
|
||||
jar_bytes(&jar_path, &[("p/EncodingSample.java", &gbk)]);
|
||||
let loaded = read_entry(&jar_path, "p/EncodingSample.java", Some("gbk")).unwrap();
|
||||
assert_eq!((loaded.text.as_str(), loaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
// An explicit encoding wins over detection, even when it decodes to replacement characters.
|
||||
let forced = read_entry(&jar_path, "p/EncodingSample.java", Some("utf-8")).unwrap();
|
||||
assert!(forced.text.contains('\u{FFFD}'));
|
||||
assert_eq!(forced.encoding, "utf-8");
|
||||
assert!(read_entry(&jar_path, "p/EncodingSample.java", Some("not-an-encoding")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_sources_in_maven_caches_decode_non_utf8() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let m2 = tmp.path().join("m2/com/example/encoding-sample/1.0.0");
|
||||
let gbk = encoding_rs::GBK.encode(GBK_SAMPLE).0.into_owned();
|
||||
let facade = "@file:JvmName(\"SampleUtils\")\npackage example.encoding\n\n/** 中文注释。 */\nfun message() = \"中文编码测试:是否启用\"\n";
|
||||
let facade_gbk = encoding_rs::GBK.encode(facade).0.into_owned();
|
||||
jar(&m2.join("encoding-sample-1.0.0.jar"), &[]);
|
||||
jar_bytes(
|
||||
&m2.join("encoding-sample-1.0.0-sources.jar"),
|
||||
&[("example/encoding/EncodingSample.java", &gbk), ("example/encoding/Renamed.kt", &facade_gbk)],
|
||||
);
|
||||
let uri = |class: &str| {
|
||||
format!("jar://{}!/{class}", m2.join("encoding-sample-1.0.0.jar").to_string_lossy().replace('\\', "/"))
|
||||
};
|
||||
for (class, text) in
|
||||
[("example/encoding/EncodingSample.class", GBK_SAMPLE), ("example/encoding/SampleUtils.class", facade)]
|
||||
{
|
||||
let (source_uri, loaded) = class_source(&uri(class), None).unwrap_or_else(|| panic!("{class}"));
|
||||
assert!(
|
||||
source_uri.ends_with("-sources.jar!/example/encoding/EncodingSample.java")
|
||||
|| source_uri.ends_with("-sources.jar!/example/encoding/Renamed.kt")
|
||||
);
|
||||
assert_eq!(loaded.text, text, "{class}");
|
||||
assert_eq!(loaded.encoding, "gbk", "{class}");
|
||||
// The manually attached archive path decodes the same way.
|
||||
let jdt = "jdt://contents/encoding-sample-1.0.0.jar/example.encoding/EncodingSample.class?=project/x";
|
||||
let (_, attached) = source_in_archive(jdt, &m2.join("encoding-sample-1.0.0-sources.jar"), None).unwrap();
|
||||
assert_eq!((attached.text.as_str(), attached.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
}
|
||||
// Reload with an explicit encoding re-reads the same source.
|
||||
let (_, reloaded) = class_source(&uri("example/encoding/EncodingSample.class"), Some("gbk")).unwrap();
|
||||
assert_eq!((reloaded.text.as_str(), reloaded.encoding.as_str()), (GBK_SAMPLE, "gbk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,6 +148,8 @@ pub struct Server {
|
||||
/// The instance whose just registered external project is loading, until an open file's
|
||||
/// diagnostics arrive (0: none).
|
||||
pub external_loading: std::sync::atomic::AtomicU64,
|
||||
/// kotlin-lsp's project import as the current instance last reported it.
|
||||
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -193,6 +195,7 @@ impl Server {
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
external_loading: std::sync::atomic::AtomicU64::new(0),
|
||||
import_state: watch::channel(None).0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +252,20 @@ impl Server {
|
||||
*self.started.lock() = Some(Instant::now());
|
||||
// A crash report quotes only this instance's output, not its predecessors'.
|
||||
self.stderr.lock().clear();
|
||||
self.import_state.send_replace(None);
|
||||
self.phase.send_replace(Phase::Starting);
|
||||
Some(st.generation)
|
||||
}
|
||||
|
||||
/// Publishes instance `generation`'s import state unless a newer start replaced it; checked
|
||||
/// under the state lock so it cannot land after [`Self::begin_start`]'s reset.
|
||||
pub(crate) fn set_import_state(&self, generation: u64, state: crate::kotlin_lsp::ImportState) {
|
||||
let st = self.state.lock();
|
||||
if st.generation == generation {
|
||||
self.import_state.send_replace(Some(state));
|
||||
}
|
||||
}
|
||||
|
||||
/// Starting → Missing/Failed, publishing the status the failed start reports
|
||||
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
|
||||
/// to say why it has no server, and must not still see `Starting`.
|
||||
|
||||
@@ -357,6 +357,14 @@ pub struct VirtualDocument {
|
||||
pub description: Option<String>,
|
||||
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
|
||||
pub decompiled: bool,
|
||||
/// Charset the content was decoded with (a WHATWG label, e.g. `gbk`);
|
||||
/// `utf-8` for text the server decoded itself.
|
||||
pub encoding: String,
|
||||
pub has_bom: bool,
|
||||
/// "Reload with Encoding" can re-decode the origin's bytes (archive
|
||||
/// entries, JDT sources with an editable attachment encoding); false for
|
||||
/// server-generated content such as decompiled code.
|
||||
pub can_reload_encoding: bool,
|
||||
}
|
||||
|
||||
/// Reference updates for a file / folder move (`workspace/willRenameFiles`).
|
||||
|
||||
@@ -23,9 +23,20 @@ const PATH: &AsciiSet = &CONTROLS
|
||||
.add(b'}');
|
||||
|
||||
pub fn path_to_uri_string(path: &Path) -> String {
|
||||
let s = path.to_string_lossy();
|
||||
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.into_owned() };
|
||||
let s = if cfg!(windows) {
|
||||
dunce::simplified(path).to_string_lossy().replace('\\', "/")
|
||||
} else {
|
||||
path.to_string_lossy().into_owned()
|
||||
};
|
||||
let mut out = String::from("file://");
|
||||
// `\\server\share\p` → `file://server/share/p` (RFC 8089 Appendix E.3).
|
||||
let unc = s.strip_prefix("//?/UNC/").or_else(|| s.strip_prefix("//")).filter(|_| cfg!(windows));
|
||||
if let Some(unc) = unc {
|
||||
let (host, rest) = unc.split_at(unc.find('/').unwrap_or(unc.len()));
|
||||
out.extend(utf8_percent_encode(host, PATH));
|
||||
out.extend(utf8_percent_encode(rest, PATH));
|
||||
return out;
|
||||
}
|
||||
if !s.starts_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
@@ -41,21 +52,30 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a `file:` URI to a path; `None` for other schemes.
|
||||
/// Converts a `file:` URI to a path; `None` for other schemes. On Windows a
|
||||
/// host names a share (`file://server/share/p` → `\\server\share\p`) and the
|
||||
/// drive letter is upper-cased, as the rest of the IDE spells it.
|
||||
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
|
||||
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
|
||||
// Skip an authority (`file://localhost/...`).
|
||||
let path = match rest.find('/') {
|
||||
Some(0) => rest,
|
||||
Some(i) if rest[..i].eq_ignore_ascii_case("localhost") => &rest[i..],
|
||||
_ => return None,
|
||||
};
|
||||
let (host, path) = rest.split_at(rest.find('/')?);
|
||||
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
|
||||
let path = path.split(['?', '#']).next().unwrap_or(path);
|
||||
let decoded = percent_decode_str(path).decode_utf8().ok()?;
|
||||
if cfg!(windows) {
|
||||
if !local {
|
||||
let host = percent_decode_str(host).decode_utf8().ok()?;
|
||||
return Some(PathBuf::from(format!(r"\\{host}{}", decoded.replace('/', "\\"))));
|
||||
}
|
||||
// `/C:/x` → `C:\x`
|
||||
let trimmed = decoded.strip_prefix('/').filter(|s| s.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
|
||||
return Some(PathBuf::from(trimmed.replace('/', "\\")));
|
||||
let mut path = trimmed.replace('/', "\\");
|
||||
if path.as_bytes().get(1) == Some(&b':') {
|
||||
path[..1].make_ascii_uppercase();
|
||||
}
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
if !local {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(decoded.into_owned()))
|
||||
}
|
||||
@@ -89,6 +109,40 @@ mod tests {
|
||||
assert_eq!(uri_to_path("jdt://contents/x"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn remote_hosts_are_not_local_paths() {
|
||||
assert_eq!(uri_to_path("file://server/share/a.ts"), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_uris() {
|
||||
for (uri, path) in [
|
||||
("file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
|
||||
("file:///c:/x/y.ts", r"C:\x\y.ts"),
|
||||
("file:///c%3A/x/y.ts", r"C:\x\y.ts"),
|
||||
("file:///C%3a/x", r"C:\x"),
|
||||
("file://localhost/d:/x", r"D:\x"),
|
||||
("file://server/share/a.ts", r"\\server\share\a.ts"),
|
||||
("file://server/share/a%20b/c.ts", r"\\server\share\a b\c.ts"),
|
||||
("file:////server/share/a.ts", r"\\server\share\a.ts"),
|
||||
] {
|
||||
let got = uri_to_path(uri).unwrap();
|
||||
assert_eq!(got.as_os_str(), path, "{uri}");
|
||||
}
|
||||
assert_eq!(uri_to_path("file://server"), None);
|
||||
for (path, uri, back) in [
|
||||
(r"C:\a b\c.rs", "file:///C:/a%20b/c.rs", r"C:\a b\c.rs"),
|
||||
(r"\\?\C:\x\y.ts", "file:///C:/x/y.ts", r"C:\x\y.ts"),
|
||||
(r"\\server\share\a b.ts", "file://server/share/a%20b.ts", r"\\server\share\a b.ts"),
|
||||
(r"\\?\UNC\server\share\a.ts", "file://server/share/a.ts", r"\\server\share\a.ts"),
|
||||
] {
|
||||
assert_eq!(path_to_uri(Path::new(path)).as_str(), uri, "{path}");
|
||||
assert_eq!(uri_to_path(uri).unwrap().as_os_str(), back, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_targets() {
|
||||
let jdt = "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/x.jar%3Cjava.lang(String.class";
|
||||
|
||||
@@ -7,7 +7,7 @@ use ide_core::{
|
||||
use ide_jsonrpc::{Handler, NoopHandler, Peer};
|
||||
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
|
||||
use ide_lsp::types::*;
|
||||
use ide_lsp::{LaunchError, Launcher, LspManager};
|
||||
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable, LaunchError, Launcher, LspManager};
|
||||
use ide_text::AppliedChange;
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
@@ -306,6 +306,7 @@ fn harness_configured(
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
@@ -3211,6 +3212,33 @@ async fn rename_merges_the_servers_that_can_rename_there() {
|
||||
assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_rename_the_server_withdraws_while_analysing_is_asked_again_then_explained() {
|
||||
let h = harness(&[("dart", incremental_caps())]);
|
||||
let (doc, path) = h.open("a.mk", "count = 1\n");
|
||||
h.wait_status("dart", ServerStatus::Ready).await;
|
||||
let dart = h.launcher.state("dart");
|
||||
let r = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":5}});
|
||||
dart.respond("textDocument/rename", json!({ "changes": { uri_of(&path): [{ "range": r, "newText": "total" }] } }));
|
||||
// Withdrawn while the server analyses an edit, then answered.
|
||||
dart.errors.lock().insert("textDocument/rename".into(), (-32801, "Document was modified".into()));
|
||||
let renaming = {
|
||||
let mgr = h.mgr.clone();
|
||||
tokio::spawn(async move { mgr.rename(doc, Pos::new(0, 1), "total".into(), Default::default()).await })
|
||||
};
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
dart.errors.lock().clear();
|
||||
let edit = renaming.await.unwrap().unwrap().edit;
|
||||
assert_eq!(edits_in(&edit, &path), [(rng(0, 0, 0, 5), "total".into())]);
|
||||
assert!(dart.requested("textDocument/rename").len() >= 2);
|
||||
|
||||
// Withdrawn every time: an error that says so, not a silent cancellation.
|
||||
dart.errors.lock().insert("textDocument/rename".into(), (-32801, "Document was modified".into()));
|
||||
let err = h.mgr.rename(doc, Pos::new(0, 1), "total".into(), Default::default()).await.unwrap_err();
|
||||
assert_ne!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
assert!(err.message.contains("try again"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn angular_server_is_asked_about_typescript_only_in_component_templates() {
|
||||
let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())];
|
||||
@@ -3641,6 +3669,340 @@ async fn jdt_class_file_contents() {
|
||||
assert_eq!(source.title, "WithSources.java");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_library_sources_reload_with_the_attachment_encoding() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "import example.encoding.EncodingSample;\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/encoding-sample-1.0.jar/example.encoding/EncodingSample.class?=demo/%5C/m2%5C/encoding-sample-1.0.jar%3Cexample.encoding%28EncodingSample.class";
|
||||
|
||||
// The mock jdtls decodes the GBK attachment with the configured charset:
|
||||
// until the encoding is updated, the text arrives garbled (U+FFFD).
|
||||
let source = "package example.encoding;\n\n/** 中文编码测试。 */\npublic final class EncodingSample {}";
|
||||
let garbled =
|
||||
"package example.encoding;\n\n/** \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} */\npublic final class EncodingSample {}";
|
||||
let attachment_encoding: Arc<Mutex<Option<String>>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let encoding = attachment_encoding.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => {
|
||||
let text = if encoding.lock().as_deref() == Some("gbk") { source } else { garbled };
|
||||
Some(json!(text))
|
||||
}
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => Some(json!({
|
||||
"errorMessage": null,
|
||||
"attributes": {
|
||||
"jarPath": "/m2/encoding-sample-1.0.jar",
|
||||
"sourceAttachmentPath": "/m2/encoding-sample-1.0-sources.jar",
|
||||
"sourceAttachmentEncoding": encoding.lock().clone(),
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
})),
|
||||
"java.project.updateSourceAttachment" => {
|
||||
let attrs = ¶ms["arguments"][0]["attributes"];
|
||||
*encoding.lock() = attrs["sourceAttachmentEncoding"].as_str().map(str::to_owned);
|
||||
Some(json!({ "errorMessage": null, "attributes": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert_eq!(vd.text, garbled);
|
||||
assert_eq!(vd.encoding, "utf-8", "no attachment encoding configured yet");
|
||||
assert!(vd.can_reload_encoding);
|
||||
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, source);
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert!(reloaded.can_reload_encoding);
|
||||
// The update kept the attachment path (omitting it would drop the sources)
|
||||
// and set the encoding; the contents were fetched again.
|
||||
let updates: Vec<Value> = s
|
||||
.requested("workspace/executeCommand")
|
||||
.into_iter()
|
||||
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
|
||||
.collect();
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0]["arguments"][0]["classFileUri"], jdt);
|
||||
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentPath"], "/m2/encoding-sample-1.0-sources.jar");
|
||||
assert_eq!(updates[0]["arguments"][0]["attributes"]["sourceAttachmentEncoding"], "gbk");
|
||||
assert!(s.requested("java/classFileContents").len() >= 2);
|
||||
// The cache serves the re-decoded text without another round trip.
|
||||
assert_eq!(h.mgr.virtual_document(jdt).await.unwrap().text, source);
|
||||
// Round trip back to UTF-8.
|
||||
let back = h.mgr.reload_virtual(jdt, "utf-8").await.unwrap();
|
||||
assert_eq!(back.text, garbled);
|
||||
assert_eq!(back.encoding, "utf-8");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_waits_for_the_maven_container_refresh() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
|
||||
// m2e persists and refreshes the container in a background job: resolve
|
||||
// keeps reporting the old encoding for a few calls after the update.
|
||||
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let state = state.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => {
|
||||
let enc = state.lock().0.clone();
|
||||
Some(json!(if enc.as_deref() == Some("gbk") {
|
||||
"package p; /** 中文 */ class Lib {}"
|
||||
} else {
|
||||
"package p; /** \u{FFFD} */ class Lib {}"
|
||||
}))
|
||||
}
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => {
|
||||
let mut st = state.lock();
|
||||
st.1 += 1;
|
||||
let reported = if st.1 > 2 { st.0.clone() } else { None };
|
||||
Some(json!({
|
||||
"attributes": {
|
||||
"sourceAttachmentPath": "/m2/lib-sources.jar",
|
||||
"sourceAttachmentEncoding": reported,
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
}))
|
||||
}
|
||||
"java.project.updateSourceAttachment" => {
|
||||
let mut st = state.lock();
|
||||
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
|
||||
st.1 = 0;
|
||||
Some(json!({ "errorMessage": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert!(vd.text.contains('\u{FFFD}'));
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert!(state.lock().1 >= 3, "polled until the container reported the new encoding");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_retries_when_a_restored_container_ignores_the_update() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
|
||||
// jdtls replaces the encoding attribute by reference equality: an entry
|
||||
// restored from the saved container state keeps the old value on the first
|
||||
// update (a duplicate attribute is appended); only a second update, after
|
||||
// the container was refreshed, applies the new one.
|
||||
let state: Arc<Mutex<(Option<String>, usize)>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let state = state.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => {
|
||||
let enc = state.lock().0.clone();
|
||||
Some(json!(if enc.as_deref() == Some("gbk") {
|
||||
"package p; /** 中文 */ class Lib {}"
|
||||
} else {
|
||||
"package p; /** \u{FFFD} */ class Lib {}"
|
||||
}))
|
||||
}
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": {
|
||||
"sourceAttachmentPath": "/m2/lib-sources.jar",
|
||||
"sourceAttachmentEncoding": state.lock().0.clone(),
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
})),
|
||||
"java.project.updateSourceAttachment" => {
|
||||
let mut st = state.lock();
|
||||
st.1 += 1;
|
||||
if st.1 > 1 {
|
||||
st.0 = params["arguments"][0]["attributes"]["sourceAttachmentEncoding"]
|
||||
.as_str()
|
||||
.map(str::to_owned);
|
||||
}
|
||||
Some(json!({ "errorMessage": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert!(vd.text.contains('\u{FFFD}'));
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, "package p; /** 中文 */ class Lib {}");
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
let updates: Vec<Value> = s
|
||||
.requested("workspace/executeCommand")
|
||||
.into_iter()
|
||||
.filter(|p| p["command"] == "java.project.updateSourceAttachment")
|
||||
.collect();
|
||||
assert_eq!(updates.len(), 2, "the ignored first update was retried once");
|
||||
assert!(updates.iter().all(|u| u["arguments"][0]["attributes"]["sourceAttachmentPath"] == "/m2/lib-sources.jar"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_reads_the_attachment_when_the_server_buffer_is_stale() {
|
||||
use std::io::Write;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sources = tmp.path().join("lib-sources.jar");
|
||||
let source = "package p; /** 中文 */ class Lib {}";
|
||||
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&sources).unwrap());
|
||||
zip.start_file("p/Lib.java", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(&gbk).unwrap();
|
||||
zip.finish().unwrap();
|
||||
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Lib.class?=demo/%5C/m2%5C/lib.jar%3Cp%28Lib.class";
|
||||
// JDT caches the decoded buffer of a class file: even after the attachment
|
||||
// encoding is updated, classFileContents keeps returning the old decoding.
|
||||
let encoding: Arc<Mutex<Option<String>>> = Arc::default();
|
||||
*s.responder.lock() = Some(Arc::new({
|
||||
let encoding = encoding.clone();
|
||||
let sources = sources.clone();
|
||||
move |_, method, params| match method {
|
||||
"java/classFileContents" => Some(json!("package p; /** \u{FFFD} */ class Lib {}")),
|
||||
"workspace/executeCommand" => match params["command"].as_str()? {
|
||||
"java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": {
|
||||
"sourceAttachmentPath": sources.to_string_lossy(),
|
||||
"sourceAttachmentEncoding": encoding.lock().clone(),
|
||||
"canEditEncoding": true,
|
||||
}
|
||||
})),
|
||||
"java.project.updateSourceAttachment" => {
|
||||
*encoding.lock() =
|
||||
params["arguments"][0]["attributes"]["sourceAttachmentEncoding"].as_str().map(str::to_owned);
|
||||
Some(json!({ "errorMessage": null }))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(jdt).await.unwrap();
|
||||
assert!(vd.text.contains('\u{FFFD}'));
|
||||
let reloaded = h.mgr.reload_virtual(jdt, "gbk").await.unwrap();
|
||||
assert_eq!(reloaded.text, source, "read from the attachment, not the stale server buffer");
|
||||
assert_eq!(reloaded.encoding, "gbk");
|
||||
assert!(reloaded.can_reload_encoding);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jdt_reload_is_refused_when_the_sources_cannot_be_re_decoded() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let class =
|
||||
|name: &str| format!("jdt://contents/lib.jar/p/{name}.class?=demo/%5C/m2%5C/lib.jar%3Cp%28{name}.class");
|
||||
s.respond("java/classFileContents", json!("package p;\npublic class Lib {}"));
|
||||
|
||||
// A container that does not allow editing the attachment encoding.
|
||||
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
|
||||
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": { "sourceAttachmentPath": "/m2/lib-sources.jar", "canEditEncoding": false }
|
||||
})),
|
||||
_ => None,
|
||||
}));
|
||||
let vd = h.mgr.virtual_document(&class("ReadOnly")).await.unwrap();
|
||||
assert!(!vd.can_reload_encoding);
|
||||
let err = h.mgr.reload_virtual(&class("ReadOnly"), "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("cannot be changed"), "{err}");
|
||||
|
||||
// Decompiled content has no attached sources to re-decode.
|
||||
*s.responder.lock() = Some(Arc::new(|_, method, params| match method {
|
||||
"workspace/executeCommand" if params["command"] == "java.project.resolveSourceAttachment" => Some(json!({
|
||||
"attributes": { "sourceAttachmentPath": null, "canEditEncoding": true }
|
||||
})),
|
||||
_ => None,
|
||||
}));
|
||||
s.respond(
|
||||
"java/classFileContents",
|
||||
json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Lib {}"),
|
||||
);
|
||||
let decompiled = h.mgr.virtual_document(&class("Decompiled")).await.unwrap();
|
||||
assert!(decompiled.decompiled && !decompiled.can_reload_encoding);
|
||||
let err = h.mgr.reload_virtual(&class("Decompiled"), "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("no attached sources"), "{err}");
|
||||
|
||||
// The resolve command fails (unqueryable container): no reload offered.
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "unsupported".into()));
|
||||
let unresolved = h.mgr.virtual_document(&class("Unresolved")).await.unwrap();
|
||||
assert_eq!(unresolved.encoding, "utf-8");
|
||||
assert!(!unresolved.can_reload_encoding);
|
||||
let err = h.mgr.reload_virtual(&class("Unresolved"), "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("cannot resolve"), "{err}");
|
||||
|
||||
// Server-generated content of other schemes cannot be re-decoded at all.
|
||||
let err = h.mgr.reload_virtual("csharp:/p/App.csproj/decompiled/Lib.cs", "gbk").await.unwrap_err();
|
||||
assert!(err.message.contains("generated by a language server"), "{err}");
|
||||
// Unknown encodings are refused before any server round trip.
|
||||
assert!(h.mgr.reload_virtual(&class("ReadOnly"), "not-an-encoding").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jar_library_sources_reload_with_another_encoding() {
|
||||
use std::io::Write;
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
let dir = repo.path().join("com/example/encoding-sample/1.0.0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = "package example.encoding\n\n/** 中文编码测试。 */\nfun message() = \"中文编码测试:是否启用\"\n";
|
||||
let gbk = encoding_rs::GBK.encode(source).0.into_owned();
|
||||
let jar_bytes = |path: &Path, entries: &[(&str, &[u8])]| {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
|
||||
for (name, bytes) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
};
|
||||
jar_bytes(&dir.join("encoding-sample-1.0.jar"), &[("example/encoding/EncodingSampleKt.class", b"\0")]);
|
||||
jar_bytes(&dir.join("encoding-sample-1.0-sources.jar"), &[("example/encoding/EncodingSample.kt", &gbk)]);
|
||||
let jar_uri = |jar: &Path, entry: &str| format!("jar://{}!/{entry}", uri_of(jar).trim_start_matches("file://"));
|
||||
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
|
||||
h.open("Main.kt", "fun main() {}\n");
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
let class = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/EncodingSampleKt.class");
|
||||
let entry = jar_uri(&dir.join("encoding-sample-1.0-sources.jar"), "example/encoding/EncodingSample.kt");
|
||||
|
||||
let vd = h.mgr.virtual_document(&class).await.unwrap();
|
||||
assert_eq!(vd.text, source);
|
||||
assert_eq!(vd.encoding, "gbk");
|
||||
assert!(vd.can_reload_encoding);
|
||||
// An explicit encoding wins over detection, even when it garbles the text.
|
||||
let forced = h.mgr.reload_virtual(&class, "utf-8").await.unwrap();
|
||||
assert!(forced.text.contains('\u{FFFD}'));
|
||||
assert_eq!(forced.encoding, "utf-8");
|
||||
let back = h.mgr.reload_virtual(&class, "gbk").await.unwrap();
|
||||
assert_eq!((back.text.as_str(), back.encoding.as_str()), (source, "gbk"));
|
||||
// Non-class entries reload the same way.
|
||||
let entry_vd = h.mgr.virtual_document(&entry).await.unwrap();
|
||||
assert_eq!((entry_vd.text.as_str(), entry_vd.encoding.as_str()), (source, "gbk"));
|
||||
let entry_reload = h.mgr.reload_virtual(&entry, "gbk").await.unwrap();
|
||||
assert_eq!(entry_reload.text, source);
|
||||
// Missing sources fail cleanly.
|
||||
let missing = jar_uri(&dir.join("encoding-sample-1.0.jar"), "example/encoding/Missing.class");
|
||||
assert!(h.mgr.reload_virtual(&missing, "gbk").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
let mut caps = incremental_caps();
|
||||
@@ -5963,6 +6325,208 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
|
||||
assert_eq!(e.message, "plain doesn't support Pull Members Up");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ kotlin-lsp debug server
|
||||
|
||||
type KotlinDebug = Result<KotlinDebugServer, KotlinDebugUnavailable>;
|
||||
|
||||
fn kotlin_lsp_harness() -> (Harness, Arc<MockState>) {
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
|
||||
let s = h.launcher.state("kotlin-lsp");
|
||||
s.respond("workspace/executeCommand", json!(4711));
|
||||
(h, s)
|
||||
}
|
||||
|
||||
fn spawn_kotlin_debug_server(
|
||||
h: &Harness,
|
||||
path: &Path,
|
||||
import_wait: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> tokio::task::JoinHandle<KotlinDebug> {
|
||||
let (mgr, path, cancel) = (h.mgr.clone(), path.to_owned(), cancel.clone());
|
||||
tokio::spawn(async move { mgr.kotlin_debug_server(&path, import_wait, &cancel).await })
|
||||
}
|
||||
|
||||
async fn joined(debug: tokio::task::JoinHandle<KotlinDebug>) -> KotlinDebug {
|
||||
tokio::time::timeout(Duration::from_secs(10), debug).await.expect("kotlin_debug_server returned").unwrap()
|
||||
}
|
||||
|
||||
fn import_progress(s: &MockState, value: Value) {
|
||||
s.peer().notify("$/progress", json!({ "token": "import", "value": value })).unwrap();
|
||||
}
|
||||
|
||||
fn import_finished(s: &MockState, folders: &[(&Path, &str)]) {
|
||||
let folders: Vec<Value> = folders
|
||||
.iter()
|
||||
.map(|(dir, status)| json!({ "folderUri": format!("{}/", uri_of(dir)), "tool": "gradle", "status": status }))
|
||||
.collect();
|
||||
s.peer().notify("intellij/workspaceImportState", json!({ "phase": "FINISHED", "folders": folders })).unwrap();
|
||||
}
|
||||
|
||||
fn debug_server_requests(s: &MockState) -> Vec<Value> {
|
||||
s.requested("workspace/executeCommand")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn waits_for_the_import_before_starting_the_debug_server() {
|
||||
// kotlin-lsp sends the progress end and FINISHED within the same millisecond, in either order.
|
||||
for finished_first in [false, true] {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let main = h.root.join("Main.kt");
|
||||
std::fs::write(&main, "fun main() {}").unwrap();
|
||||
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
// Repeats every few seconds while the project stays usable: never waited for.
|
||||
s.peer()
|
||||
.notify("$/progress", json!({ "token": "index", "value": { "kind": "begin", "title": "Indexing" } }))
|
||||
.unwrap();
|
||||
import_progress(&s, json!({ "kind": "begin", "title": "Importing" }));
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Busy).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(debug_server_requests(&s).is_empty(), "started while importing");
|
||||
|
||||
if finished_first {
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(debug_server_requests(&s).is_empty(), "started while the Importing task runs");
|
||||
import_progress(&s, json!({ "kind": "end" }));
|
||||
} else {
|
||||
import_progress(&s, json!({ "kind": "end" }));
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(debug_server_requests(&s).is_empty(), "started before FINISHED");
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
}
|
||||
|
||||
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
|
||||
assert_eq!(
|
||||
debug_server_requests(&s),
|
||||
vec![json!({ "command": "start_debug_server", "arguments": [uri_of(&h.root)] })],
|
||||
"finished_first: {finished_first}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn still_importing_after_the_wait_is_reported() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(200), &CancellationToken::new()).await;
|
||||
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert!(debug_server_requests(&s).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancelling_the_import_wait_reports_still_importing() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let cancel = CancellationToken::new();
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(60), &cancel);
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(!debug.is_finished());
|
||||
cancel.cancel();
|
||||
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert!(debug_server_requests(&s).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_import_of_the_module_is_reported() {
|
||||
// Another module that failed does not keep this one from debugging.
|
||||
for other_module_failed in [false, true] {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let main = h.root.join("app/Main.kt");
|
||||
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
let lib = h.root.join("lib");
|
||||
let (folders, expected) = if other_module_failed {
|
||||
(
|
||||
vec![(h.root.as_path(), "SUCCESS"), (lib.as_path(), "FAILED")],
|
||||
Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }),
|
||||
)
|
||||
} else {
|
||||
(vec![(h.root.as_path(), "FAILED")], Err(KotlinDebugUnavailable::ImportFailed))
|
||||
};
|
||||
import_finished(&s, &folders);
|
||||
assert_eq!(joined(debug).await, expected, "other_module_failed: {other_module_failed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_import_is_reported_through_a_symlinked_root() {
|
||||
// The debugged path names the root through a symlink, the folder URI names its target,
|
||||
// and the file does not exist yet.
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let links = tempfile::tempdir().unwrap();
|
||||
let link = links.path().join("project");
|
||||
std::os::unix::fs::symlink(&h.root, &link).unwrap();
|
||||
let debug =
|
||||
spawn_kotlin_debug_server(&h, &link.join("app/Main.kt"), Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
import_finished(&s, &[(&h.root, "FAILED")]);
|
||||
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::ImportFailed));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_older_finished_import_does_not_count_after_a_restart() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
assert!(joined(debug).await.is_ok());
|
||||
|
||||
let id = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(300), &CancellationToken::new()).await;
|
||||
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
|
||||
assert_eq!(debug_server_requests(&s).len(), 1);
|
||||
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
|
||||
assert_eq!(debug_server_requests(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn without_kotlin_lsp_the_reason_is_not_configured() {
|
||||
let wait = Duration::from_secs(1);
|
||||
let cancel = CancellationToken::new();
|
||||
let h = harness_for("kotlin", "kt", &[("kotlin-language-server", incremental_caps())], &[]);
|
||||
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotConfigured));
|
||||
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
s.missing.store(true, Ordering::SeqCst);
|
||||
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotInstalled));
|
||||
|
||||
let (h, _) = kotlin_lsp_harness();
|
||||
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
|
||||
assert_eq!(
|
||||
h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await,
|
||||
Err(KotlinDebugUnavailable::NotStarted("needs JDK 21".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_debug_server_error_is_reported() {
|
||||
let (h, s) = kotlin_lsp_harness();
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "boom".into()));
|
||||
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
|
||||
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
|
||||
import_finished(&s, &[(&h.root, "SUCCESS")]);
|
||||
match joined(debug).await {
|
||||
Err(KotlinDebugUnavailable::Failed(m)) => assert!(m.contains("boom"), "{m}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
|
||||
s.errors.lock().clear();
|
||||
for port in [json!(0), json!(65536), json!("4711"), Value::Null] {
|
||||
s.respond("workspace/executeCommand", port.clone());
|
||||
match h.mgr.kotlin_debug_server(&h.root, Duration::from_secs(1), &CancellationToken::new()).await {
|
||||
Err(KotlinDebugUnavailable::Failed(m)) => {
|
||||
assert_eq!(m, format!("unexpected debug server port: {port}"))
|
||||
}
|
||||
other => panic!("{port}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
|
||||
for encoding in ["utf-16", "utf-8"] {
|
||||
@@ -6151,3 +6715,69 @@ async fn missing_injected_server_uses_the_normal_single_install_notification() {
|
||||
h.mgr.did_close(host);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
/// #2248: `go.mod` files are served by gopls with the language id it
|
||||
/// understands, and its pushed diagnostics reach the Problems view.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn gomod_files_are_served_by_gopls() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let registry = Arc::new(LanguageRegistry::load(None).unwrap());
|
||||
// The bundled configs route Go files of every kind to gopls.
|
||||
for lang in ["go", "go.mod", "go.work"] {
|
||||
let cfg = registry.get(&LanguageId::new(lang)).unwrap_or_else(|| panic!("{lang} has no language definition"));
|
||||
assert!(cfg.language_servers.iter().any(|s| s.name == "gopls"), "{lang} is not served by gopls");
|
||||
}
|
||||
let launcher = Arc::new(MockLauncher::default());
|
||||
*launcher.state("gopls").caps.lock() = incremental_caps();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
|
||||
let text = "module example.com/app\n\ngo 1.22\n";
|
||||
let path = root.join("go.mod");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
assert!(mgr.serves_path(&path));
|
||||
mgr.did_open(DocId::next(), &path, &LanguageId::new("go.mod"), 1, text);
|
||||
let gopls = launcher.state("gopls");
|
||||
eventually(|| !gopls.notified("textDocument/didOpen").is_empty()).await;
|
||||
// gopls only treats the file as a module file for the `go.mod` id.
|
||||
let opened = &gopls.notified("textDocument/didOpen")[0]["textDocument"];
|
||||
assert_eq!(opened["languageId"], json!("go.mod"));
|
||||
// The module root (the go.mod itself is the marker) scopes the server.
|
||||
let init = &gopls.requested("initialize")[0];
|
||||
assert_eq!(init["rootUri"], json!(uri_of(&root)));
|
||||
// Same startup options as when a `.go` file launches the server first.
|
||||
assert_eq!(init["initializationOptions"]["semanticTokens"], json!(true));
|
||||
// Pushed go.mod diagnostics surface with gopls as their source.
|
||||
gopls
|
||||
.peer()
|
||||
.notify(
|
||||
"textDocument/publishDiagnostics",
|
||||
json!({ "uri": uri_of(&path), "diagnostics": [{
|
||||
"range": {"start": {"line": 2, "character": 3}, "end": {"line": 2, "character": 7}},
|
||||
"severity": 1, "message": "missing requirement", "source": "gopls" }] }),
|
||||
)
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.any(|e| matches!(e, IdeEvent::Diagnostics { path: p, source, .. } if p.0 == path && source == "gopls"))
|
||||
})
|
||||
.await;
|
||||
let items = events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, items, .. } if p.0 == path => Some(items.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.next_back()
|
||||
.unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].message, "missing requirement");
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
framework_files: Vec::new(),
|
||||
formatter: None,
|
||||
scratch_template: None,
|
||||
tree_sitter: None,
|
||||
|
||||
@@ -69,7 +69,7 @@ pub struct TransferError {
|
||||
}
|
||||
|
||||
impl TransferReport {
|
||||
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
|
||||
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
|
||||
self.errors.push(TransferError { path: path.into(), message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,70 @@ impl RemoteService {
|
||||
r
|
||||
}
|
||||
|
||||
/// Remote Host › Download to…: `remotes` (folders recursively) into the local folder `dir`,
|
||||
/// each under its own name, without the server path above it. Existing files are replaced.
|
||||
pub async fn download_into(
|
||||
&self,
|
||||
connection: &FsConnection,
|
||||
remotes: &[String],
|
||||
dir: &Path,
|
||||
cancel: &CancellationToken,
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> IdeResult<TransferReport> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let mut report = TransferReport::default();
|
||||
let mut folders = Vec::new();
|
||||
let mut jobs = Vec::new();
|
||||
for remote in remotes {
|
||||
let Some(top) = download_target(dir, remote, remote) else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
match fs.stat(remote).await {
|
||||
Ok(Some(e)) if e.is_dir => {
|
||||
folders.push(top);
|
||||
let mut entries = Vec::new();
|
||||
if let Err(e) = remote_tree(&fs, remote, cancel, &mut entries).await {
|
||||
report.fail(remote.clone(), e);
|
||||
}
|
||||
for entry in entries {
|
||||
match download_target(dir, remote, &entry.path) {
|
||||
Some(local) if entry.is_dir => folders.push(local),
|
||||
Some(local) => jobs.push((entry.path, local)),
|
||||
None => report.skipped += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => jobs.push((remote.clone(), top)),
|
||||
Ok(None) => report.fail(remote.clone(), IdeError::not_found(format!("{remote} does not exist"))),
|
||||
Err(e) => report.fail(remote.clone(), e),
|
||||
}
|
||||
}
|
||||
for folder in &folders {
|
||||
if let Err(e) = tokio::fs::create_dir_all(folder).await {
|
||||
report.fail(folder.display().to_string(), e.into());
|
||||
}
|
||||
}
|
||||
for (i, (remote, local)) in jobs.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
progress(remote, i, jobs.len());
|
||||
if let Some(parent) = local.parent()
|
||||
&& let Err(e) = tokio::fs::create_dir_all(parent).await
|
||||
{
|
||||
report.fail(remote.clone(), e.into());
|
||||
continue;
|
||||
}
|
||||
match fs.download(remote, local).await {
|
||||
Ok(()) => report.files += 1,
|
||||
Err(e) => report.fail(remote.clone(), e),
|
||||
}
|
||||
}
|
||||
fs.close().await;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// --- save hook -----------------------------------------------------------------
|
||||
|
||||
/// After a document save: uploads a remote file's copy, or (automatic upload)
|
||||
@@ -497,3 +561,70 @@ fn cache_key(connection: &FsConnection) -> String {
|
||||
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and folders under remote folder `dir` (symlinked folders are not followed).
|
||||
async fn remote_tree(
|
||||
fs: &RemoteFs,
|
||||
dir: &str,
|
||||
cancel: &CancellationToken,
|
||||
out: &mut Vec<RemoteEntry>,
|
||||
) -> IdeResult<()> {
|
||||
for e in fs.list(dir).await? {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let descend = e.is_dir && !e.is_symlink;
|
||||
let path = e.path.clone();
|
||||
out.push(e);
|
||||
if descend {
|
||||
Box::pin(remote_tree(fs, &path, cancel, out)).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where remote `path`, inside the downloaded `selected` file or folder, lands under local `dir`:
|
||||
/// `dir/<name of selected>/<rest>`. `None` for a name that would leave `dir`.
|
||||
fn download_target(dir: &Path, selected: &str, path: &str) -> Option<PathBuf> {
|
||||
let selected = selected.trim_end_matches('/');
|
||||
let name = selected.rsplit('/').next()?;
|
||||
let rest = path.trim_end_matches('/').strip_prefix(selected)?;
|
||||
if !rest.is_empty() && !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let mut local = dir.to_path_buf();
|
||||
for part in std::iter::once(name).chain(rest.split('/').filter(|p| !p.is_empty())) {
|
||||
if matches!(part, "" | "." | "..") || part.contains('\\') || (cfg!(windows) && part.contains(':')) {
|
||||
return None;
|
||||
}
|
||||
local.push(part);
|
||||
}
|
||||
Some(local)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_into_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn downloads_keep_only_the_selected_name() {
|
||||
let dir = Path::new("/p/project");
|
||||
let selected = "/home/u/www/site/current/drush";
|
||||
assert_eq!(download_target(dir, selected, selected), Some(dir.join("drush")));
|
||||
assert_eq!(download_target(dir, "/home/u/www/site/current/drush/", selected), Some(dir.join("drush")));
|
||||
assert_eq!(
|
||||
download_target(dir, selected, "/home/u/www/site/current/drush/src/a.php"),
|
||||
Some(dir.join("drush").join("src").join("a.php"))
|
||||
);
|
||||
assert_eq!(download_target(dir, "/srv/app.log", "/srv/app.log"), Some(dir.join("app.log")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_would_leave_the_folder_are_refused() {
|
||||
let dir = Path::new("/p/project");
|
||||
assert_eq!(download_target(dir, "/srv/a", "/srv/a/../../etc/passwd"), None);
|
||||
assert_eq!(download_target(dir, "/srv/a", "/srv/ab/x"), None, "a sibling with the same prefix");
|
||||
assert_eq!(download_target(dir, "/", "/etc"), None, "the root has no name");
|
||||
assert_eq!(download_target(dir, "/srv/..", "/srv/.."), None);
|
||||
}
|
||||
}
|
||||
@@ -319,3 +319,37 @@ async fn browser_mappings_resolve_symlinks_and_download_to_the_project() {
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(local).unwrap(), "from the server");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_into_copies_only_the_selected_items() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let server = tmp.path().join("srv/www/site/current");
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(server.join("drush/sub")).unwrap();
|
||||
std::fs::create_dir_all(server.join("drush/empty")).unwrap();
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
std::fs::write(server.join("drush/a.txt"), "a").unwrap();
|
||||
std::fs::write(server.join("drush/sub/b.txt"), "b").unwrap();
|
||||
std::fs::write(server.join("index.php"), "php").unwrap();
|
||||
std::fs::write(project.join("index.php"), "old").unwrap();
|
||||
let service = service(tmp.path());
|
||||
let remotes = [server.join("drush"), server.join("index.php")].map(|p| p.to_str().unwrap().to_owned());
|
||||
let report = service
|
||||
.download_into(&FsConnection::Local, &remotes, &project, &CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.files, 3, "{report:?}");
|
||||
assert!(report.errors.is_empty(), "{report:?}");
|
||||
assert_eq!(std::fs::read_to_string(project.join("drush/a.txt")).unwrap(), "a");
|
||||
assert_eq!(std::fs::read_to_string(project.join("drush/sub/b.txt")).unwrap(), "b");
|
||||
assert!(project.join("drush/empty").is_dir());
|
||||
assert_eq!(std::fs::read_to_string(project.join("index.php")).unwrap(), "php");
|
||||
assert!(!project.join("srv").exists() && !project.join("www").exists());
|
||||
|
||||
let missing = [server.join("gone").to_str().unwrap().to_owned()];
|
||||
let report = service
|
||||
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.files, report.errors.len()), (0, 1), "{report:?}");
|
||||
}
|
||||
@@ -863,6 +863,48 @@ fn module_dir(root: &Path, module: Option<&Path>) -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// The module directory a Tomcat config's WAR belongs to. Created configs carry it as `cwd`
|
||||
/// (set from the selected artifact); when it is missing, the selected artifact id still names
|
||||
/// the module — `maven:<pom>` its POM, `gradle:…` its build — instead of falling back to the
|
||||
/// project root, whose own `src` rarely exists and would make every launch look current.
|
||||
fn artifact_module_dir(root: &Path, config: &RunConfig) -> PathBuf {
|
||||
if config.cwd.is_some() {
|
||||
return module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
|
||||
}
|
||||
let selected = config.extras.params.get("artifact").filter(|s| !s.is_empty());
|
||||
if let Some(id) = selected {
|
||||
if let Some(pom) = id.strip_prefix("maven:") {
|
||||
// `maven:pom.xml` is the root module itself.
|
||||
return match Path::new(pom).parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
Some(dir) => root.join(dir),
|
||||
None => root.to_path_buf(),
|
||||
};
|
||||
}
|
||||
if let Some(rest) = id.strip_prefix("gradle:") {
|
||||
// `gradle:<project path>` for the root build, else `gradle:["<build dir>", "<path>"]`.
|
||||
if let Ok((dir, path)) = serde_json::from_str::<(String, String)>(rest) {
|
||||
return root.join(gradle_module_dir(&dir, &path));
|
||||
}
|
||||
if !rest.trim_matches(':').is_empty() {
|
||||
return root.join(gradle_module_dir("", rest));
|
||||
}
|
||||
}
|
||||
}
|
||||
root.to_path_buf()
|
||||
}
|
||||
|
||||
/// Maps a Gradle project path (`:` for the build itself, `:web` for a subproject) below its
|
||||
/// build directory. Custom `projectDir` mappings are invisible here, so this is the
|
||||
/// conventional layout; still closer than the project root.
|
||||
fn gradle_module_dir(dir: &str, path: &str) -> PathBuf {
|
||||
let mut module = PathBuf::from(dir);
|
||||
let rel = path.trim_start_matches(':').replace(':', "/");
|
||||
if !rel.is_empty() {
|
||||
module.push(rel);
|
||||
}
|
||||
module
|
||||
}
|
||||
|
||||
/// The newest modification time of `path` or, for a directory, anything below it.
|
||||
fn newest_mtime_under(path: &Path) -> Option<std::time::SystemTime> {
|
||||
let meta = fs::metadata(path).ok()?;
|
||||
@@ -902,14 +944,16 @@ fn find_built_war(root: &Path, module: Option<&Path>) -> Option<PathBuf> {
|
||||
|
||||
/// Whether the WAR a Tomcat config deploys is newer than the module's sources, so its
|
||||
/// "Before launch › Build" task can be skipped (IDEA's up-to-date check). Conservative:
|
||||
/// a missing WAR or a newer source/build file means it is not current. Only the module's
|
||||
/// own sources and build files are inspected, not dependency modules.
|
||||
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool {
|
||||
/// a missing WAR or a newer source/build file means it is not current. Besides the WAR
|
||||
/// module's sources, web resources and build files, the sources and build files of every
|
||||
/// module in `modules` (project-relative build modules) are inspected: a mapper XML or
|
||||
/// class in a module the WAR depends on is packaged into it as a JAR.
|
||||
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig, modules: &[PathBuf]) -> bool {
|
||||
let p = &config.extras.params;
|
||||
if p.get("deployment").map(String::as_str).unwrap_or("artifact") != "artifact" {
|
||||
return true;
|
||||
}
|
||||
let module = module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
|
||||
let module = artifact_module_dir(root, config);
|
||||
let selected = p.get("artifact").filter(|s| !s.is_empty());
|
||||
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
|
||||
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
|
||||
@@ -923,7 +967,24 @@ pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool
|
||||
};
|
||||
let Ok(war_time) = fs::metadata(&war).and_then(|m| m.modified()) else { return false };
|
||||
let mut newest = newest_mtime_under(&module.join("src"));
|
||||
for dir in [module.as_path(), root] {
|
||||
// Web content packaged from outside `src` (a custom `warSourceDirectory`, `webResources`
|
||||
// directories, a custom Gradle web app dir) also lands in the WAR — the running-session
|
||||
// sync copies it from these same folders — so a save there must rebuild too (#2685).
|
||||
for (dir, _) in web_sources(&module) {
|
||||
if let Some(time) = newest_mtime_under(&dir)
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
{
|
||||
newest = Some(time);
|
||||
}
|
||||
}
|
||||
let other_modules = modules.iter().map(|m| root.join(m));
|
||||
for dir in [module.clone(), root.to_path_buf()].into_iter().chain(other_modules) {
|
||||
if dir != module
|
||||
&& let Some(time) = newest_mtime_under(&dir.join("src"))
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
{
|
||||
newest = Some(time);
|
||||
}
|
||||
for name in ["pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
if let Ok(time) = fs::metadata(dir.join(name)).and_then(|m| m.modified())
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
@@ -946,6 +1007,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
|
||||
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
|
||||
let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim()));
|
||||
let module = artifact_module_dir(root, config);
|
||||
let war = match recorded.filter(|_| id_matches) {
|
||||
Some(war) => {
|
||||
if war.is_file() && is_war(&war) {
|
||||
@@ -954,7 +1016,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
|
||||
}
|
||||
}
|
||||
None => find_built_war(root, config.cwd.as_ref().map(|c| c.0.as_path())).ok_or_else(|| {
|
||||
None => find_built_war(root, Some(module.as_path())).ok_or_else(|| {
|
||||
invalid("No built WAR artifact found - build the project first, or add 'Build' to Before launch")
|
||||
})?,
|
||||
};
|
||||
@@ -1592,10 +1654,119 @@ mod tests {
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
// The WAR was packed after the source was written.
|
||||
assert!(artifact_is_current(root, &base, &c));
|
||||
assert!(artifact_is_current(root, &base, &c, &[]));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&source, "class App { int changed; }").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c));
|
||||
assert!(!artifact_is_current(root, &base, &c, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_check_follows_the_selected_artifact_when_cwd_is_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module = root.join("web");
|
||||
fs::create_dir_all(module.join("src/main/resources")).unwrap();
|
||||
fs::create_dir_all(module.join("target")).unwrap();
|
||||
fs::write(root.join("pom.xml"), "<project/>").unwrap();
|
||||
fs::write(module.join("pom.xml"), "<project/>").unwrap();
|
||||
let source = module.join("src/main/resources/applicationContext.xml");
|
||||
fs::write(&source, "<beans/>").unwrap();
|
||||
let war = module.join("target/app.war");
|
||||
archive(&war, &[("WEB-INF/web.xml", "<web-app/>")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
// A previous IDE build recorded its archive, as `ide_build::web::build` leaves behind.
|
||||
fs::write(base.join("artifact-id.txt"), "maven:web/pom.xml").unwrap();
|
||||
fs::write(base.join("artifact-path.txt"), war.to_string_lossy().as_bytes()).unwrap();
|
||||
// Older configs may not carry the artifact's module as `cwd` (the UI sets it today).
|
||||
let mut c = config();
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
assert!(artifact_is_current(root, &base, &c, &[]));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&source, "<beans><edited/></beans>").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_check_watches_web_resources_outside_src() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module = root.join("web");
|
||||
fs::create_dir_all(module.join("src/main/java")).unwrap();
|
||||
fs::create_dir_all(module.join("target")).unwrap();
|
||||
fs::create_dir_all(module.join("WebContent/WEB-INF")).unwrap();
|
||||
fs::write(
|
||||
module.join("pom.xml"),
|
||||
"<project><plugin><artifactId>maven-war-plugin</artifactId><configuration><webResources>\
|
||||
<resource><directory>WebContent</directory></resource></webResources></configuration></plugin></project>",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(module.join("src/main/java/App.java"), "class App {}").unwrap();
|
||||
let web_xml = module.join("WebContent/WEB-INF/web.xml");
|
||||
fs::write(&web_xml, "<web-app/>").unwrap();
|
||||
let war = module.join("target/app.war");
|
||||
archive(&war, &[("WEB-INF/web.xml", "<web-app/>"), ("index.html", "built")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let mut c = config();
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
assert!(artifact_is_current(root, &base, &c, &[]));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&web_xml, "<web-app><edited/></web-app>").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_to_date_check_watches_the_modules_the_war_depends_on() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let web = root.join("web");
|
||||
let dao = root.join("dao");
|
||||
fs::create_dir_all(web.join("src/main/java")).unwrap();
|
||||
fs::create_dir_all(web.join("target")).unwrap();
|
||||
fs::create_dir_all(dao.join("src/main/resources/mapper")).unwrap();
|
||||
fs::write(web.join("pom.xml"), "<project/>").unwrap();
|
||||
fs::write(dao.join("pom.xml"), "<project/>").unwrap();
|
||||
fs::write(web.join("src/main/java/App.java"), "class App {}").unwrap();
|
||||
let mapper = dao.join("src/main/resources/mapper/UserMapper.xml");
|
||||
fs::write(&mapper, "<mapper/>").unwrap();
|
||||
archive(&web.join("target/app.war"), &[("WEB-INF/web.xml", "<web-app/>")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let mut c = config();
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
let modules = [PathBuf::from(""), PathBuf::from("web"), PathBuf::from("dao")];
|
||||
assert!(artifact_is_current(root, &base, &c, &modules));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&mapper, "<mapper><select/></mapper>").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c, &modules), "a dependency module's XML changed");
|
||||
assert!(artifact_is_current(root, &base, &c, &[]), "without the build's modules only the WAR module counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_module_prefers_cwd_then_selected_artifact() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module_of = |cwd: Option<&str>, artifact: Option<&str>| {
|
||||
let mut c = config();
|
||||
c.cwd = cwd.map(|d| ide_core::IpcPath(PathBuf::from(d)));
|
||||
if let Some(id) = artifact {
|
||||
c.extras.params.insert("artifact".into(), id.into());
|
||||
}
|
||||
artifact_module_dir(root, &c)
|
||||
};
|
||||
// The UI keeps `cwd` on the artifact's module; it wins over a stale selection.
|
||||
assert_eq!(module_of(Some("web"), Some("maven:other/pom.xml")), root.join("web"));
|
||||
assert_eq!(module_of(None, Some("maven:web/pom.xml")), root.join("web"));
|
||||
assert_eq!(module_of(None, Some("maven:pom.xml")), root);
|
||||
assert_eq!(module_of(None, Some("gradle::web")), root.join("web"));
|
||||
assert_eq!(module_of(None, Some("gradle::")), root);
|
||||
assert_eq!(module_of(None, Some("gradle:[\"sub\",\":web\"]")), root.join("sub/web"));
|
||||
assert_eq!(module_of(None, Some("other:whatever")), root);
|
||||
assert_eq!(module_of(None, None), root);
|
||||
assert_eq!(module_of(Some("web"), None), root.join("web"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
install_bundle(
|
||||
data_dir,
|
||||
"java-usages",
|
||||
"1.12.0",
|
||||
"1.13.0",
|
||||
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ fn jdk_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<Libr
|
||||
Some(files) => files,
|
||||
None => {
|
||||
let java = [".classpath", ".project"].iter().any(|f| root.join(f).is_file())
|
||||
|| root.join(".idea").is_dir()
|
||||
|| idea_java_project(root)
|
||||
|| !s.languages.java.jdk.trim().is_empty()
|
||||
|| !s.project_structure.source_roots.is_empty();
|
||||
if !java {
|
||||
@@ -561,6 +561,27 @@ pub(crate) fn classpath_entries(dir: &Path, text: &str) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
|
||||
/// An IntelliJ project (`.idea`) that is a Java one: a Java SDK or language level in
|
||||
/// `misc.xml`, or a Java module file. GoLand, PyCharm and WebStorm write `.idea` too, so the
|
||||
/// folder alone doesn't make a project need the JDK.
|
||||
fn idea_java_project(root: &Path) -> bool {
|
||||
let idea = root.join(".idea");
|
||||
if !idea.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let misc = std::fs::read_to_string(idea.join("misc.xml")).unwrap_or_default();
|
||||
if misc.contains("JavaSDK") || misc.contains("languageLevel=\"JDK_") {
|
||||
return true;
|
||||
}
|
||||
let java_module = |dir: &Path| {
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().any(|e| {
|
||||
e.path().extension().is_some_and(|x| x == "iml")
|
||||
&& std::fs::read_to_string(e.path()).is_ok_and(|t| t.contains("JAVA_MODULE"))
|
||||
})
|
||||
};
|
||||
java_module(root) || java_module(&idea)
|
||||
}
|
||||
|
||||
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
|
||||
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
|
||||
let mut roots = named_roots(vec![(name.clone(), jdk.home.clone())]);
|
||||
@@ -1263,3 +1284,32 @@ mod go_cache_tests {
|
||||
assert_eq!(groups[1].roots.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod idea_project_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_idea_folder_marks_a_java_project_only_when_it_declares_java() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
assert!(!idea_java_project(root));
|
||||
// GoLand / PyCharm write `.idea` without any Java in it.
|
||||
std::fs::create_dir_all(root.join(".idea")).unwrap();
|
||||
std::fs::write(root.join(".idea/misc.xml"), r#"<project version="4"><component name="GOROOT" /></project>"#)
|
||||
.unwrap();
|
||||
std::fs::write(root.join(".idea/demo.iml"), r#"<module type="WEB_MODULE" version="4" />"#).unwrap();
|
||||
assert!(!idea_java_project(root));
|
||||
// A Java module file, at the root or in `.idea`.
|
||||
std::fs::write(root.join("app.iml"), r#"<module type="JAVA_MODULE" version="4" />"#).unwrap();
|
||||
assert!(idea_java_project(root));
|
||||
std::fs::remove_file(root.join("app.iml")).unwrap();
|
||||
// The project JDK in misc.xml.
|
||||
std::fs::write(
|
||||
root.join(".idea/misc.xml"),
|
||||
r#"<project version="4"><component name="ProjectRootManager" languageLevel="JDK_17" project-jdk-name="17" project-jdk-type="JavaSDK" /></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(idea_java_project(root));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use crate::{ChangeKind, FileStatus, Repo};
|
||||
use gix::status::index_worktree::Item as WtItem;
|
||||
use gix::status::plumbing::index_as_worktree::{Change as WtChange, EntryStatus};
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::ignored::IgnoredNames;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -15,10 +16,43 @@ struct Entry {
|
||||
}
|
||||
|
||||
pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
||||
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
|
||||
// gitoxide stops at the first IO error (a directory it may not read), where git warns
|
||||
// and lists the rest: git answers then.
|
||||
let (map, conflicts) = gix_status(r, &ignored).or_else(|e| {
|
||||
tracing::debug!("status via gitoxide failed, asking git: {}", e.message);
|
||||
cli_status(r, &ignored)
|
||||
})?;
|
||||
Ok(map
|
||||
.into_iter()
|
||||
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
|
||||
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
|
||||
// changes too, not just untracked hits above. Conflicts stay visible so merges
|
||||
// cannot stall on a file the user cannot see.
|
||||
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
|
||||
.map(|(rel, mut e)| {
|
||||
if conflicts.contains(&rel) {
|
||||
e.staged = None;
|
||||
e.renamed_from = None;
|
||||
e.unstaged = Some(ChangeKind::Conflicted);
|
||||
}
|
||||
FileStatus {
|
||||
path: r.abs_path(&rel).into(),
|
||||
rel_path: rel,
|
||||
staged: e.staged,
|
||||
unstaged: e.unstaged,
|
||||
renamed_from: e.renamed_from,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
type Entries = (BTreeMap<String, Entry>, HashSet<String>);
|
||||
|
||||
fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
|
||||
let repo = r.gix()?;
|
||||
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
|
||||
let mut conflicts: HashSet<String> = HashSet::new();
|
||||
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
|
||||
|
||||
// Stamp before status can read the index: stamping after the load could capture
|
||||
// an external `git switch`, `add` or commit's new index and allow the stale
|
||||
@@ -118,28 +152,60 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map
|
||||
.into_iter()
|
||||
.filter(|(_, e)| e.staged.is_some() || e.unstaged.is_some())
|
||||
// Ignored names mean "the IDE treats these as nonexistent": hide their tracked
|
||||
// changes too, not just untracked hits above. Conflicts stay visible so merges
|
||||
// cannot stall on a file the user cannot see.
|
||||
.filter(|(rel, _)| conflicts.contains(rel) || !ignored.is_ignored_path(rel))
|
||||
.map(|(rel, mut e)| {
|
||||
if conflicts.contains(&rel) {
|
||||
e.staged = None;
|
||||
e.renamed_from = None;
|
||||
e.unstaged = Some(ChangeKind::Conflicted);
|
||||
Ok((map, conflicts))
|
||||
}
|
||||
|
||||
/// `git status --porcelain=v2`: what git lists, unreadable directories skipped.
|
||||
fn cli_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
|
||||
let out = crate::cli::run(r.root(), &["status", "--porcelain=v2", "-z", "--untracked-files=all"], None)?;
|
||||
Ok(parse_porcelain_v2(&String::from_utf8_lossy(&out.stdout), ignored))
|
||||
}
|
||||
|
||||
fn parse_porcelain_v2(out: &str, ignored: &IgnoredNames) -> Entries {
|
||||
let kind = |c: u8| match c {
|
||||
b'M' | b'T' => Some(ChangeKind::Modified),
|
||||
b'A' | b'C' => Some(ChangeKind::Added),
|
||||
b'D' => Some(ChangeKind::Deleted),
|
||||
b'R' => Some(ChangeKind::Renamed),
|
||||
_ => None,
|
||||
};
|
||||
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
|
||||
let mut conflicts: HashSet<String> = HashSet::new();
|
||||
let mut records = out.split('\0');
|
||||
while let Some(record) = records.next() {
|
||||
let (tag, rest) = record.split_once(' ').unwrap_or((record, ""));
|
||||
match tag {
|
||||
// `1 XY sub mH mI mW hH hI path`, `2 … Xscore path` + NUL + the original path.
|
||||
"1" | "2" => {
|
||||
let fields = if tag == "1" { 8 } else { 9 };
|
||||
let mut parts = rest.splitn(fields, ' ');
|
||||
let xy = parts.next().unwrap_or("..").as_bytes();
|
||||
let Some(path) = parts.nth(fields - 2) else { continue };
|
||||
let e = map.entry(path.to_owned()).or_default();
|
||||
e.staged = xy.first().copied().and_then(kind);
|
||||
// Intent to add shows as `.A`; worktree renames are off, as with gitoxide.
|
||||
e.unstaged = xy.get(1).copied().and_then(kind).filter(|k| *k != ChangeKind::Renamed);
|
||||
if tag == "2" {
|
||||
let from = records.next().unwrap_or_default();
|
||||
if e.staged == Some(ChangeKind::Renamed) {
|
||||
e.renamed_from = Some(from.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
FileStatus {
|
||||
path: r.abs_path(&rel).into(),
|
||||
rel_path: rel,
|
||||
staged: e.staged,
|
||||
unstaged: e.unstaged,
|
||||
renamed_from: e.renamed_from,
|
||||
// `u XY sub m1 m2 m3 mW h1 h2 h3 path`
|
||||
"u" => {
|
||||
if let Some(path) = rest.splitn(10, ' ').nth(9) {
|
||||
conflicts.insert(path.to_owned());
|
||||
map.entry(path.to_owned()).or_default().unstaged = Some(ChangeKind::Conflicted);
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
"?" if !rest.is_empty() && !ignored.is_ignored_path(rest) => {
|
||||
map.entry(rest.to_owned()).or_default().unstaged = Some(ChangeKind::Untracked);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(map, conflicts)
|
||||
}
|
||||
|
||||
/// Lets a test land an external index write in the window between the status run
|
||||
@@ -227,4 +293,59 @@ mod tests {
|
||||
let staged = Command::new("git").current_dir(dir).args(["diff", "--cached", "--stat"]).output().unwrap();
|
||||
assert!(staged.stdout.is_empty(), "{}", String::from_utf8_lossy(&staged.stdout));
|
||||
}
|
||||
|
||||
/// git's porcelain answer (used when gitoxide can't read the tree) matches gitoxide's.
|
||||
#[test]
|
||||
fn git_and_gitoxide_report_the_same_status() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
git(dir, &["config", "user.name", "Test User"]);
|
||||
git(dir, &["config", "user.email", "[email protected]"]);
|
||||
git(dir, &["config", "commit.gpgsign", "false"]);
|
||||
for f in ["modified.txt", "staged.txt", "deleted.txt", "staged_del.txt", "both.txt", "conflict.txt"] {
|
||||
std::fs::write(dir.join(f), "a\n").unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("rename_me.txt"), "some longer content\nthat is stable\nfor rename detection\n")
|
||||
.unwrap();
|
||||
commit_all(dir, "base");
|
||||
git(dir, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(dir.join("conflict.txt"), "side\n").unwrap();
|
||||
commit_all(dir, "side");
|
||||
git(dir, &["checkout", "-q", "main"]);
|
||||
std::fs::write(dir.join("conflict.txt"), "main\n").unwrap();
|
||||
commit_all(dir, "main");
|
||||
let merge = Command::new("git").current_dir(dir).args(["merge", "-q", "side"]).output().unwrap();
|
||||
assert!(!merge.status.success());
|
||||
|
||||
std::fs::write(dir.join("modified.txt"), "b\n").unwrap();
|
||||
std::fs::write(dir.join("staged.txt"), "b\n").unwrap();
|
||||
git(dir, &["add", "staged.txt"]);
|
||||
std::fs::remove_file(dir.join("deleted.txt")).unwrap();
|
||||
git(dir, &["rm", "-q", "staged_del.txt"]);
|
||||
std::fs::write(dir.join("both.txt"), "b\n").unwrap();
|
||||
git(dir, &["add", "both.txt"]);
|
||||
std::fs::write(dir.join("both.txt"), "c\n").unwrap();
|
||||
git(dir, &["mv", "rename_me.txt", "renamed with space.txt"]);
|
||||
std::fs::create_dir_all(dir.join("new dir")).unwrap();
|
||||
std::fs::write(dir.join("new dir/untracked.txt"), "u\n").unwrap();
|
||||
std::fs::write(dir.join("intent.txt"), "i\n").unwrap();
|
||||
git(dir, &["add", "-N", "intent.txt"]);
|
||||
|
||||
let repo = Repo::discover(dir).unwrap().expect("repository found");
|
||||
let ignored = IgnoredNames::default();
|
||||
let show = |(map, conflicts): Entries| {
|
||||
let mut c: Vec<_> = conflicts.into_iter().collect();
|
||||
c.sort();
|
||||
let m: Vec<_> = map
|
||||
.into_iter()
|
||||
.map(|(k, e)| format!("{k}: {:?} {:?} {:?}", e.staged, e.unstaged, e.renamed_from))
|
||||
.collect();
|
||||
(m, c)
|
||||
};
|
||||
let from_git = show(cli_status(&repo, &ignored).unwrap());
|
||||
assert_eq!(from_git, show(gix_status(&repo, &ignored).unwrap()));
|
||||
assert!(from_git.0.iter().any(|l| l.starts_with("renamed with space.txt: Some(Renamed)")), "{from_git:?}");
|
||||
assert_eq!(from_git.1, ["conflict.txt"]);
|
||||
}
|
||||
}
|
||||
@@ -2015,3 +2015,24 @@ fn reword_refuses_commits_outside_the_current_branch() {
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
assert!(fx.repo().reword(&side, "other").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn status_skips_a_directory_it_cannot_read() {
|
||||
// `git status` warns "could not open directory: Permission denied" and lists the rest.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.commit_all("init");
|
||||
fx.write("a.txt", "b\n");
|
||||
fx.write("new.txt", "n\n");
|
||||
let locked = fx.path("locked");
|
||||
std::fs::create_dir(&locked).unwrap();
|
||||
std::fs::write(locked.join("inside.txt"), "x").unwrap();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let st = fx.repo().status();
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let st = st.unwrap();
|
||||
assert_eq!(status_of(&st, "a.txt").unstaged, Some(ChangeKind::Modified));
|
||||
assert_eq!(status_of(&st, "new.txt").unstaged, Some(ChangeKind::Untracked));
|
||||
}
|
||||
@@ -359,6 +359,32 @@ fn sync_error_is_tolerable(e: &std::io::Error) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// On Windows a file can't be replaced while another process has it open without
|
||||
/// delete sharing: an antivirus or indexer scan, a sync client, a program reading
|
||||
/// its config. Those let go within moments, so the replacing rename is retried briefly.
|
||||
const RENAME_ATTEMPTS: u32 = 10;
|
||||
const RENAME_PAUSE: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Access denied (os error 5), sharing and lock violations (32, 33) on Windows.
|
||||
fn held_by_another_process(e: &std::io::Error) -> bool {
|
||||
cfg!(windows) && matches!(e.raw_os_error(), Some(5 | 32 | 33))
|
||||
}
|
||||
|
||||
fn retrying(
|
||||
attempts: u32,
|
||||
pause: std::time::Duration,
|
||||
transient: impl Fn(&std::io::Error) -> bool,
|
||||
mut op: impl FnMut() -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
for _ in 1..attempts {
|
||||
match op() {
|
||||
Err(e) if transient(&e) => std::thread::sleep(pause),
|
||||
r => return r,
|
||||
}
|
||||
}
|
||||
op()
|
||||
}
|
||||
|
||||
/// Writes `bytes` to `path` atomically: temp file in the same directory, fsync,
|
||||
/// rename over the target. Preserves the permissions of an existing file and
|
||||
/// writes through symlinks. Falls back to an in-place write when the directory
|
||||
@@ -441,7 +467,7 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: b
|
||||
fs::set_permissions(&tmp, permissions)?;
|
||||
}
|
||||
drop(file);
|
||||
fs::rename(&tmp, &target)
|
||||
retrying(RENAME_ATTEMPTS, RENAME_PAUSE, held_by_another_process, || fs::rename(&tmp, &target))
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
@@ -539,6 +565,58 @@ mod tests {
|
||||
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_is_retried_only_while_a_file_is_held() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
let in_use = || Error::from_raw_os_error(32);
|
||||
let transient = |e: &Error| e.raw_os_error() == Some(32);
|
||||
let zero = std::time::Duration::ZERO;
|
||||
for (fails, outcome) in [(0, true), (3, true), (RENAME_ATTEMPTS - 1, true), (RENAME_ATTEMPTS, false)] {
|
||||
let mut calls = 0;
|
||||
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
|
||||
calls += 1;
|
||||
if calls <= fails { Err(in_use()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(r.is_ok(), outcome, "{fails} failures");
|
||||
assert_eq!(calls, (fails + 1).min(RENAME_ATTEMPTS));
|
||||
}
|
||||
let mut calls = 0;
|
||||
let r = retrying(RENAME_ATTEMPTS, zero, transient, || {
|
||||
calls += 1;
|
||||
Err(Error::from(ErrorKind::NotFound))
|
||||
});
|
||||
assert_eq!(r.unwrap_err().kind(), ErrorKind::NotFound);
|
||||
assert_eq!(calls, 1, "other errors fail at once");
|
||||
|
||||
for code in [5, 32, 33] {
|
||||
assert_eq!(held_by_another_process(&Error::from_raw_os_error(code)), cfg!(windows), "os error {code}");
|
||||
}
|
||||
assert!(!held_by_another_process(&Error::from(ErrorKind::NotFound)));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn saving_waits_for_a_windows_file_lock_to_be_released() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
|
||||
// No sharing at all (os error 32), and readers that don't allow deletion (os error 5).
|
||||
for share in [0, FILE_SHARE_READ | FILE_SHARE_WRITE] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.json");
|
||||
fs::write(&path, b"old").unwrap();
|
||||
let held = fs::OpenOptions::new().read(true).share_mode(share).open(&path).unwrap();
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
drop(held);
|
||||
});
|
||||
let result = write_atomic(&path, b"new");
|
||||
release.join().unwrap();
|
||||
result.unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"new", "share mode {share}");
|
||||
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1, "no temp file left behind");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn strict_write_does_not_truncate_when_sibling_creation_is_denied() {
|
||||
|
||||
@@ -25,6 +25,11 @@ pub struct DocumentInfo {
|
||||
pub line_ending: LineEnding,
|
||||
pub read_only: bool,
|
||||
pub dirty: bool,
|
||||
/// Whether "Reload with Encoding" can re-decode this document's source
|
||||
/// bytes: true for files and virtual documents the backend can re-read
|
||||
/// (archive entries, JDT sources with an editable attachment encoding,
|
||||
/// Git revisions); false for server-generated content (decompiled code).
|
||||
pub can_reload_encoding: bool,
|
||||
/// Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
|
||||
/// structure and folding are disabled; the editor should drop heavy features.
|
||||
#[specta(optional)]
|
||||
@@ -143,6 +148,9 @@ struct DocState {
|
||||
observed: bool,
|
||||
binary: bool,
|
||||
dirty: bool,
|
||||
can_reload_encoding: bool,
|
||||
/// The user picked `encoding`; reloads keep it instead of detecting again.
|
||||
encoding_chosen: bool,
|
||||
/// Disk state we last loaded or wrote; changes matching it are our own.
|
||||
disk_mtime: f64,
|
||||
disk_len: u64,
|
||||
@@ -339,6 +347,7 @@ impl Documents {
|
||||
line_ending: st.doc.line_ending(),
|
||||
read_only: st.read_only,
|
||||
dirty: st.dirty,
|
||||
can_reload_encoding: st.can_reload_encoding,
|
||||
large: st.large,
|
||||
editorconfig: st.editorconfig.clone(),
|
||||
uri: st.uri.clone(),
|
||||
@@ -412,6 +421,8 @@ impl Documents {
|
||||
observed,
|
||||
binary: loaded.binary,
|
||||
dirty: false,
|
||||
can_reload_encoding: true,
|
||||
encoding_chosen: false,
|
||||
disk_mtime: loaded.mtime_ms,
|
||||
disk_len: disk_len(path),
|
||||
closed: false,
|
||||
@@ -449,8 +460,19 @@ impl Documents {
|
||||
|
||||
/// Opens (or re-references) a read-only virtual document: contents a
|
||||
/// language server provides for a non-`file:` URI. It is keyed by the URI,
|
||||
/// never touches the disk and cannot be edited or saved.
|
||||
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
|
||||
/// never touches the disk and cannot be edited or saved. `encoding` /
|
||||
/// `has_bom` describe how the content was decoded (not a guess);
|
||||
/// `can_reload_encoding` tells whether [`Documents::replace_virtual`] can
|
||||
/// re-decode it ("Reload with Encoding").
|
||||
pub fn open_virtual(
|
||||
&self,
|
||||
uri: &str,
|
||||
language: LanguageId,
|
||||
text: &str,
|
||||
encoding: &str,
|
||||
has_bom: bool,
|
||||
can_reload_encoding: bool,
|
||||
) -> IdeResult<DocumentInfo> {
|
||||
let _operation = self.operations.lock();
|
||||
let key = PathBuf::from(uri);
|
||||
if let Some(info) = self.reopen(&key) {
|
||||
@@ -462,8 +484,8 @@ impl Documents {
|
||||
doc: Document::new(text),
|
||||
language,
|
||||
ui_mode: None,
|
||||
encoding: "utf-8".into(),
|
||||
has_bom: false,
|
||||
encoding: encoding.into(),
|
||||
has_bom,
|
||||
native_to_ascii: false,
|
||||
read_only: true,
|
||||
large,
|
||||
@@ -471,6 +493,8 @@ impl Documents {
|
||||
observed: true,
|
||||
binary: false,
|
||||
dirty: false,
|
||||
can_reload_encoding,
|
||||
encoding_chosen: false,
|
||||
disk_mtime: 0.0,
|
||||
disk_len: 0,
|
||||
closed: false,
|
||||
@@ -846,6 +870,7 @@ impl Documents {
|
||||
let loaded = Self::load_as(&st.path, encoding, enabled)?;
|
||||
st.native_to_ascii = enabled;
|
||||
st.encoding = loaded.encoding.clone();
|
||||
st.encoding_chosen = true;
|
||||
st.has_bom = loaded.has_bom;
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
@@ -864,12 +889,45 @@ impl Documents {
|
||||
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
|
||||
}
|
||||
st.encoding = encoding.to_owned();
|
||||
st.encoding_chosen = true;
|
||||
st.has_bom = has_bom;
|
||||
self.set_dirty(doc, st, true);
|
||||
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
|
||||
})
|
||||
}
|
||||
|
||||
/// The URI of a virtual document, or `None` for a file-backed one.
|
||||
pub fn virtual_uri(&self, doc: DocId) -> IdeResult<Option<String>> {
|
||||
self.with_doc(doc, |st| Ok(st.uri.clone()))
|
||||
}
|
||||
|
||||
/// Re-decoded content of a virtual document ("Reload with Encoding"):
|
||||
/// replaces the text and the encoding label, keeping the document
|
||||
/// read-only and clean. The caller re-reads the origin (archive entry,
|
||||
/// language server, Git blob) with the chosen encoding first.
|
||||
pub fn replace_virtual(
|
||||
&self,
|
||||
doc: DocId,
|
||||
text: &str,
|
||||
encoding: &str,
|
||||
has_bom: bool,
|
||||
) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.uri.is_none() {
|
||||
return Err(IdeError::invalid("not a virtual document"));
|
||||
}
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
st.doc.set_line_ending(LineEnding::detect(text));
|
||||
self.replace_locked(doc, st, text)?;
|
||||
st.encoding = encoding.to_owned();
|
||||
st.has_bom = has_bom;
|
||||
st.large = text.len() as u64 > ide_vfs::LARGE_FILE_THRESHOLD;
|
||||
// Reloading is not an edit: the buffer matches its source again.
|
||||
self.set_dirty(doc, st, false);
|
||||
Ok(DocumentEncoding { encoding: st.encoding.clone(), has_bom: st.has_bom })
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds or removes the BOM on a UTF-8 document; the change is written on save.
|
||||
pub fn set_bom(&self, doc: DocId, has_bom: bool) -> IdeResult<DocumentEncoding> {
|
||||
self.with_doc(doc, |st| {
|
||||
@@ -968,8 +1026,13 @@ impl Documents {
|
||||
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
|
||||
} else {
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
let native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
let loaded = if st.encoding_chosen {
|
||||
Self::load_as(&st.path, &st.encoding, native_to_ascii)?
|
||||
} else {
|
||||
Self::load_with_defaults(&st.path, &defaults)?
|
||||
};
|
||||
st.native_to_ascii = native_to_ascii;
|
||||
loaded
|
||||
};
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
|
||||
@@ -748,6 +748,7 @@ impl Documents {
|
||||
let result = self.with_doc(*id, |state| {
|
||||
self.replace_locked(*id, state, &before.doc.text())?;
|
||||
state.encoding = before.encoding.clone();
|
||||
state.encoding_chosen = before.encoding_chosen;
|
||||
state.has_bom = before.has_bom;
|
||||
state.doc.set_line_ending(before.doc.line_ending());
|
||||
state.disk_mtime = fs::metadata(&state.path).map(|m| ide_vfs::mtime_ms(&m)).unwrap_or(0.0);
|
||||
@@ -789,7 +790,11 @@ impl Documents {
|
||||
return Err(stale(path));
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?;
|
||||
let loaded = if state.encoding_chosen {
|
||||
Self::load_as(path, &state.encoding, state.native_to_ascii)?
|
||||
} else {
|
||||
ide_vfs::load_text_with_fallback(path, defaults.fallback_for(path))?
|
||||
};
|
||||
self.replace_locked(id, state, &loaded.text)?;
|
||||
state.encoding = loaded.encoding;
|
||||
state.has_bom = loaded.has_bom;
|
||||
|
||||
@@ -505,6 +505,48 @@ fn reload_and_convert_encoding() {
|
||||
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes_keep_a_chosen_encoding() {
|
||||
// Valid UTF-8 bytes, so detection alone would read them as UTF-8.
|
||||
let (first, changed) = (b"caf\xC3\xA9".as_slice(), b"caf\xC3\xA9 \xC3\xA8".as_slice());
|
||||
for (encoding, reload) in [("windows-1252", true), ("gbk", true), ("windows-1252", false)] {
|
||||
let f = fixture();
|
||||
let path = f.root.join("legacy.txt");
|
||||
fs::write(&path, first).unwrap();
|
||||
let info = f.docs.open(&path).unwrap();
|
||||
assert_eq!(info.encoding, "utf-8");
|
||||
if reload {
|
||||
f.docs.set_encoding(info.id, encoding, true).unwrap();
|
||||
} else {
|
||||
// Convert, then save: the file is now in the chosen encoding.
|
||||
f.docs.replace_all(info.id, "café").unwrap();
|
||||
f.docs.set_encoding(info.id, encoding, false).unwrap();
|
||||
f.docs.save(info.id).unwrap();
|
||||
}
|
||||
let expected =
|
||||
|bytes: &[u8]| encoding_rs::Encoding::for_label(encoding.as_bytes()).unwrap().decode(bytes).0.into_owned();
|
||||
let changed = if reload { changed.to_vec() } else { ide_vfs::encode("café è", encoding, false).unwrap() };
|
||||
fs::write(&path, &changed).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
let snapshot = f.docs.snapshot(info.id).unwrap();
|
||||
assert_eq!(snapshot.text, expected(&changed), "{encoding} reload={reload}");
|
||||
assert_eq!(snapshot.encoding, encoding, "{encoding} reload={reload}");
|
||||
assert!(!snapshot.dirty);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_changes_redetect_an_encoding_the_user_never_chose() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("plain.txt");
|
||||
fs::write(&path, "plain").unwrap();
|
||||
let info = f.docs.open(&path).unwrap();
|
||||
fs::write(&path, ide_vfs::encode("wide", "utf-16le", true).unwrap()).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
let snapshot = f.docs.snapshot(info.id).unwrap();
|
||||
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("wide", "utf-16le"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_is_added_and_removed_on_save() {
|
||||
let f = fixture();
|
||||
@@ -656,14 +698,14 @@ fn editorconfig_applies_on_open_and_save() {
|
||||
fn virtual_documents_are_read_only_and_keyed_by_uri() {
|
||||
let f = fixture();
|
||||
let uri = "jdt://contents/a.jar/p/C.class?=x";
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n").unwrap();
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n", "utf-8", false, true).unwrap();
|
||||
assert_eq!(info.uri.as_deref(), Some(uri));
|
||||
assert_eq!(info.path, PathBuf::from(uri));
|
||||
assert!(info.read_only && !info.dirty);
|
||||
assert_eq!(info.language.as_str(), "java");
|
||||
|
||||
// Re-opening shares the document; edits and saves never reach a file.
|
||||
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored").unwrap();
|
||||
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored", "utf-8", false, true).unwrap();
|
||||
assert_eq!(again.id, info.id);
|
||||
assert_eq!(again.text, "class C {}\n");
|
||||
let err = f.docs.edit(info.id, info.version, change(0, 0, "x")).unwrap_err();
|
||||
@@ -677,6 +719,68 @@ fn virtual_documents_are_read_only_and_keyed_by_uri() {
|
||||
assert_eq!(*f.rec.log.lock(), vec!["open v0 java".to_owned(), "close".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_documents_carry_their_encoding_and_reload_capability() {
|
||||
let f = fixture();
|
||||
// Archive sources decoded by the backend report the charset actually used.
|
||||
let uri = "jar:///m2/lib-1.0-sources.jar!/p/C.java";
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "中文", "gbk", false, true).unwrap();
|
||||
assert_eq!((info.encoding.as_str(), info.has_bom, info.can_reload_encoding), ("gbk", false, true));
|
||||
let bom =
|
||||
f.docs.open_virtual("jar:///m2/b.jar!/p/B.java", LanguageId::new("java"), "x", "utf-8", true, true).unwrap();
|
||||
assert!(bom.has_bom);
|
||||
// Server-generated content (decompiled) cannot be re-decoded.
|
||||
let decompiled = f
|
||||
.docs
|
||||
.open_virtual(
|
||||
"csharp:/p/App.csproj/decompiled/Lib.cs",
|
||||
LanguageId::new("csharp"),
|
||||
"class Lib {}",
|
||||
"utf-8",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!decompiled.can_reload_encoding);
|
||||
// File-backed documents can always be re-read from disk.
|
||||
let path = f.root.join("f.txt");
|
||||
fs::write(&path, "x").unwrap();
|
||||
assert!(f.docs.open(&path).unwrap().can_reload_encoding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_documents_reload_with_another_encoding_without_becoming_editable() {
|
||||
let f = fixture();
|
||||
let uri = "jdt://contents/a.jar/p/C.class?=x";
|
||||
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "涓枃", "utf-8", false, true).unwrap();
|
||||
let encoding = f.docs.replace_virtual(info.id, "中文", "gbk", false).unwrap();
|
||||
assert_eq!((encoding.encoding.as_str(), encoding.has_bom), ("gbk", false));
|
||||
let snap = f.docs.snapshot(info.id).unwrap();
|
||||
assert_eq!(snap.text, "中文");
|
||||
assert_eq!(snap.encoding, "gbk");
|
||||
assert!(snap.read_only && !snap.dirty, "reloading is not an edit");
|
||||
assert!(snap.version > info.version);
|
||||
assert_eq!(f.docs.virtual_uri(info.id).unwrap().as_deref(), Some(uri));
|
||||
// The replacement reached observers and the event stream (editors resync).
|
||||
assert!(f.rec.log.lock().iter().any(|l| l.starts_with("change v") && l.contains("中文")));
|
||||
assert!(f.events.lock().iter().any(|e| matches!(e, IdeEvent::DocPatched { doc, .. } if *doc == info.id)));
|
||||
|
||||
// Read-only protection is unchanged.
|
||||
assert_eq!(f.docs.edit(info.id, snap.version, change(0, 0, "x")).unwrap_err().kind, ErrorKind::PermissionDenied);
|
||||
assert!(f.docs.set_encoding(info.id, "utf-8", false).is_err());
|
||||
assert!(f.docs.set_encoding(info.id, "gbk", true).is_err(), "the op layer routes virtual reloads");
|
||||
assert!(f.docs.set_bom(info.id, true).is_err());
|
||||
f.docs.save(info.id).unwrap();
|
||||
|
||||
// Unknown encodings are refused; file-backed documents are not virtual.
|
||||
assert!(f.docs.replace_virtual(info.id, "x", "not-an-encoding", false).is_err());
|
||||
let path = f.root.join("f.txt");
|
||||
fs::write(&path, "x").unwrap();
|
||||
let file = f.docs.open(&path).unwrap();
|
||||
assert!(f.docs.replace_virtual(file.id, "y", "utf-8", false).is_err());
|
||||
assert_eq!(f.docs.virtual_uri(file.id).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_the_writable_bit_updates_the_open_document() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -162,6 +162,21 @@ fn undo_id(events: &Mutex<Vec<IdeEvent>>) -> u32 {
|
||||
.expect("successful transaction has one undo record")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_keeps_the_encoding_chosen_for_a_file_opened_after_the_edit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("legacy.txt");
|
||||
std::fs::write(&path, b"abc\xC3\xA9").unwrap();
|
||||
let (docs, events) = recorded_documents();
|
||||
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![edit(&path, "new")], ..Default::default() }).unwrap();
|
||||
let info = docs.open(&path).unwrap();
|
||||
docs.set_encoding(info.id, "windows-1252", true).unwrap();
|
||||
assert_eq!(docs.snapshot(info.id).unwrap().text, "newé");
|
||||
docs.undo_workspace_edit(undo_id(&events)).unwrap();
|
||||
let snapshot = docs.snapshot(info.id).unwrap();
|
||||
assert_eq!((snapshot.text.as_str(), snapshot.encoding.as_str()), ("abcé", "windows-1252"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_document_version_rejects_the_whole_edit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -515,6 +530,18 @@ fn duplicate_edits_keep_versions_and_apply_one_replacement() {
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), "new_long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_inserts_at_one_position_all_apply_once_per_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("main.ts");
|
||||
std::fs::write(&path, "f(").unwrap();
|
||||
let docs = documents();
|
||||
let close = TextEdit { range: Range::new(Pos::new(0, 2), Pos::new(0, 2)), new_text: ")".into() };
|
||||
let entry = FileEdit::Edit { path: path.clone().into(), version: None, edits: vec![close.clone(), close] };
|
||||
docs.apply_workspace_edit(&WorkspaceEdit { files: vec![entry.clone(), entry], ..Default::default() }).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "f())");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_accepts_typing_that_was_reverted_to_the_saved_refactoring() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -11,9 +11,10 @@ Each open file gets its language from the definitions, checked in this order:
|
||||
|
||||
1. A per-file **Override File Type** (in the context menu of a file in the project tree or of an editor tab).
|
||||
2. An exact file name (`Dockerfile`, `go.mod`) or a file name pattern.
|
||||
3. The extension, longest match first (`.html.erb` before `.erb`).
|
||||
4. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
|
||||
5. Otherwise the file is plain text.
|
||||
3. A framework's folder: an `.html` or `.txt` file in a `templates` folder with a `manage.py` above it is a Django template.
|
||||
4. The extension, longest match first (`.html.erb` before `.erb`).
|
||||
5. The first line: a shebang such as `#!/usr/bin/env python3`, or a pattern such as `<?php`.
|
||||
6. Otherwise the file is plain text.
|
||||
|
||||
You can add or remove file name patterns per language in **Settings › Editor › File Types** (**Recognized File Types**).
|
||||
|
||||
@@ -106,6 +107,7 @@ The table lists every built-in language definition. Server and debugger names in
|
||||
| D | `d` | `.d`, `.di` | Yes | — | serve-d | — |
|
||||
| Dart | `dart` | `.dart` | Yes | Yes | dart | dart |
|
||||
| Diff | `diff` | `.diff`, `.patch`, `.rej` | Yes | — | — | — |
|
||||
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | Yes | Yes | **vscode-html-language-server** | — |
|
||||
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | Yes | — | **dockerfile-language-server** | — |
|
||||
| Elixir | `elixir` | `.ex`, `.exs` | Yes | Yes | **elixir-ls** | **elixir-ls-debugger** |
|
||||
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | Yes | — | — | — |
|
||||
|
||||
@@ -125,6 +125,12 @@ See [Debugging](/en/guide/debugging) for breakpoints, stepping and the debugger
|
||||
|
||||
Django is the only framework with dedicated support: the `manage.py runserver` and `manage.py test` configurations, the **Django tests** configuration type (target, settings module, options), running single `TestCase` tests from the gutter, and Django mode in the debugger. Other frameworks (Flask, FastAPI and so on) run as ordinary scripts or console scripts.
|
||||
|
||||
### Django templates
|
||||
|
||||
The HTML and text files in a `templates` folder of a Django project (one with a `manage.py` above it) open as **Django Template** files: the template tags, variables, filters and comments are highlighted on top of the HTML, and block tags (`{% block %}`, `{% if %}`, `{% for %}`, `{% with %}` and the others of Django and its contrib libraries) fold, indent their content and highlight their end tag. Typing `{%`, `{{` or `{#` closes the pair, finishing a block tag adds its end tag, and `{% end` completes the end tags of the open blocks. **Comment with Line Comment** puts each line in its own `{# … #}`, and **Comment with Block Comment** over several lines uses `{% comment %}`. The HTML language server and Emmet work in the markup between the tags.
|
||||
|
||||
An HTML file elsewhere with Django tags shows a banner that switches it to Django Template or keeps it as HTML; either choice is the file's **Override File Type**.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only one type checker runs at a time. Ruff runs only in projects with a Ruff configuration.
|
||||
|
||||
@@ -52,7 +52,6 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
|
||||
| Editor | General (Auto Import, Appearance, Breadcrumbs, Code Completion, Code Folding, Editor Tabs, Gutter Icons, Inline Completion, Postfix Completion, Rich-Text Copy, Smart Keys, Sticky Lines); Code Editing; Font; Color Scheme; Code Style; Inspections; Natural Languages (Spelling); File and Code Templates; File Encodings; Live Templates; File Types; Inlay Hints; TODO; Vim |
|
||||
| Plugins | Plugins |
|
||||
| Version Control | Commit; Confirmation; Directory Mappings; Issue Navigation; Log; Shelf; Git; GitHub |
|
||||
| Project Structure | Project Structure |
|
||||
| Build, Execution, Deployment | CMake; Gradle; Maven; Console; Debugger (Data Views, Stepping); Python Debugger; Run/Debug; Run Targets; Deployment (Options) |
|
||||
| Languages & Frameworks | Language Servers; Go (Build Tags, Go Modules); Java; Markdown; Node.js; PHP; Python Interpreter; Prettier; oxfmt; Ruff; Ruby; Rust; TypeScript |
|
||||
| Tools | Agents (Chat); Terminal; Actions on Save; External Tools; SSH Configurations; MCP Server |
|
||||
|
||||
@@ -11,9 +11,10 @@ OpenIDEA 用一个小的定义文件 `languages/<id>.toml` 描述每种语言:
|
||||
|
||||
1. 单个文件的 **覆盖文件类型**(在项目树中文件的右键菜单或编辑器标签页的右键菜单里)。
|
||||
2. 精确文件名(`Dockerfile`、`go.mod`)或文件名模式。
|
||||
3. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
|
||||
4. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
|
||||
5. 都不匹配时按纯文本处理。
|
||||
3. 框架目录:上层目录有 `manage.py` 的 `templates` 目录中的 `.html` 或 `.txt` 文件是 Django 模板。
|
||||
4. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
|
||||
5. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
|
||||
6. 都不匹配时按纯文本处理。
|
||||
|
||||
在 **设置 › 编辑器 › 文件类型**(**已识别的文件类型**)中可以为某种语言添加或移除文件名模式。
|
||||
|
||||
@@ -101,6 +102,7 @@ IDE 安装工具从不需要 `sudo`。工具装在本地数据目录下 IDE 自
|
||||
| D | `d` | `.d`, `.di` | 是 | — | serve-d | — |
|
||||
| Dart | `dart` | `.dart` | 是 | 是 | dart | dart |
|
||||
| Diff | `diff` | `.diff`, `.patch`, `.rej` | 是 | — | — | — |
|
||||
| Django Template | `django-html` | `.djhtml`, `templates/*.html`, `*.txt` | 是 | 是 | **vscode-html-language-server** | — |
|
||||
| Dockerfile | `dockerfile` | `.dockerfile`, `.containerfile` | 是 | — | **dockerfile-language-server** | — |
|
||||
| Elixir | `elixir` | `.ex`, `.exs` | 是 | 是 | **elixir-ls** | **elixir-ls-debugger** |
|
||||
| ERB | `erb` | `.erb`, `.html.erb`, `.rhtml`, `.js.erb`, `.text.erb`, … | 是 | — | — | — |
|
||||
|
||||
@@ -125,6 +125,12 @@ Python 调试使用 debugpy。开始调试时如果缺少 debugpy,OpenIDEA 会
|
||||
|
||||
目前只有 Django 有专门支持:`manage.py runserver` 和 `manage.py test` 运行配置、**Django 测试** 配置类型(目标、settings 模块、选项)、在左侧栏运行单个 `TestCase` 测试,以及调试器的 Django 模式。Flask、FastAPI 等其他框架按普通脚本或命令行入口运行。
|
||||
|
||||
### Django 模板
|
||||
|
||||
Django 项目(上层目录有 `manage.py`)中 `templates` 目录里的 HTML 和文本文件以 **Django Template** 打开:模板标签、变量、过滤器和注释在 HTML 之上高亮,块标签(`{% block %}`、`{% if %}`、`{% for %}`、`{% with %}` 以及 Django 和其 contrib 库的其他块标签)可以折叠、缩进内容并高亮对应的结束标签。输入 `{%`、`{{` 或 `{#` 会自动补全成对符号,写完块标签会自动添加结束标签,输入 `{% end` 会补全当前打开块的结束标签。**使用行注释** 会把每一行放进各自的 `{# … #}`,多行的 **使用块注释** 则用 `{% comment %}`。HTML 语言服务器和 Emmet 在标签之间的标记中照常工作。
|
||||
|
||||
其他位置含有 Django 标签的 HTML 文件会显示一个横幅,可以切换为 Django Template 或保留为 HTML;两种选择都会成为该文件的 **覆盖文件类型**。
|
||||
|
||||
## 限制
|
||||
|
||||
- 同一时间只运行一个类型检查器。Ruff 只在有 Ruff 配置的项目中运行。
|
||||
|
||||
@@ -52,7 +52,6 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"]
|
||||
| 编辑器 | 常规(自动导入、外观、痕迹导航、代码补全、代码折叠、编辑器标签页、装订区域图标、内联补全、后缀补全、富文本复制、智能键、粘滞行);代码编辑;字体;配色方案;代码样式;检查;自然语言(拼写);文件和代码模板;文件编码;实时模板;文件类型;嵌入提示;TODO;Vim |
|
||||
| 插件 | 插件 |
|
||||
| 版本控制 | 提交;确认;目录映射;问题导航;日志;搁置;Git;GitHub |
|
||||
| 项目结构 | 项目结构 |
|
||||
| 构建、执行、部署 | CMake;Gradle;Maven;控制台;调试器(数据视图、单步执行);Python 调试器;运行/调试;运行目标;部署(选项) |
|
||||
| 语言和框架 | 语言服务器;Go(构建标签、Go 模块);Java;Markdown;Node.js;PHP;Python 解释器;Prettier;oxfmt;Ruff;Ruby;Rust;TypeScript |
|
||||
| 工具 | 智能体(聊天);终端;保存时操作;外部工具;SSH 配置;MCP 服务器 |
|
||||
|
||||
@@ -85,16 +85,13 @@ for (const id of ["editor.fileTemplates", "editor.liveTemplates"]) {
|
||||
}
|
||||
|
||||
test("Project Structure keeps every details pane usable in a compact window", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
await expect(page.getByRole("dialog", { name: "Settings", exact: true })).toBeVisible();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/settings/model/staging.ts";
|
||||
(await import(/* @vite-ignore */ path)).selectPage("projectStructure");
|
||||
});
|
||||
await page.keyboard.press("Meta+;");
|
||||
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.setViewportSize({ width: 520, height: 760 });
|
||||
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
|
||||
await page.getByRole("tab", { name: section, exact: true }).click();
|
||||
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
|
||||
await dialog.getByRole("button", { name: section, exact: true }).click();
|
||||
const details = dialog.locator("[class*='splitLayout'] > [class*='detailPane']");
|
||||
await expect(details).toBeAttached();
|
||||
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,36 @@ test("terminal highlights visible occurrences of selected text in a single overl
|
||||
activeSession()!.term.clearSelection();
|
||||
});
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(0);
|
||||
|
||||
// Full-scrollback trim shifts a visible match into the cached margin row above viewportY; scrolling up must highlight it.
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/features/terminal/runtime.ts";
|
||||
const { activeSession } = await import(path);
|
||||
const s = activeSession()!;
|
||||
const rows = s.term.rows;
|
||||
s.term.options.scrollback = 4;
|
||||
s.term.reset();
|
||||
const total = rows + 4;
|
||||
let payload = "";
|
||||
for (let i = 0; i < total - 1; i++) {
|
||||
payload += `${i === 4 ? "needle hit" : `row-${i}`}\r\n`;
|
||||
}
|
||||
payload += "needle sel";
|
||||
await new Promise<void>((resolve) => s.term.write(payload, resolve));
|
||||
s.term.select(0, total - 1, 6);
|
||||
});
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const path = "/src/features/terminal/runtime.ts";
|
||||
const { activeSession } = await import(path);
|
||||
const s = activeSession()!;
|
||||
await new Promise<void>((resolve) => s.term.write("\r\nnext", resolve));
|
||||
s.term.scrollLines(-1);
|
||||
});
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCount(1);
|
||||
await expect(terminal.locator(".openidea-terminal-selection-match:visible")).toHaveCSS("top", "0px");
|
||||
});
|
||||
|
||||
test("terminal ⌘F highlights active/inactive search matches and seeds with a new selection", async ({
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$comment": "Human-owned: agents don't change this file or evals/*/golden/ (a PR that does is labelled needs-decision). `*Ms` are upper bounds on medians (the user's view: input → first painted frame), `recall` / `precision` lower bounds vs the golden. Keys: `<scenario>` or `<scenario>@<condition>`. Regressions vs target/eval/baseline.json need both the relative tolerance and the absolute floor.",
|
||||
"regressionTolerance": 0.2,
|
||||
"regressionFloorMs": 200,
|
||||
"defaults": {
|
||||
"feedbackMs": 100,
|
||||
"recall": 1,
|
||||
"precision": 1
|
||||
},
|
||||
"scenarios": {}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"completions": [
|
||||
"add",
|
||||
"addAll",
|
||||
"addFirst",
|
||||
"addLast",
|
||||
"cast",
|
||||
"clear",
|
||||
"contains",
|
||||
"containsAll",
|
||||
"else",
|
||||
"equals",
|
||||
"for",
|
||||
"forEach",
|
||||
"fori",
|
||||
"get",
|
||||
"getClass",
|
||||
"getFirst",
|
||||
"getLast",
|
||||
"hashCode",
|
||||
"if",
|
||||
"indexOf",
|
||||
"isEmpty",
|
||||
"iterator",
|
||||
"lastIndexOf",
|
||||
"listIterator",
|
||||
"nn",
|
||||
"not",
|
||||
"notify",
|
||||
"notifyAll",
|
||||
"null",
|
||||
"orders.nnull",
|
||||
"orders.opt",
|
||||
"orders.syserr",
|
||||
"orders.sysouf",
|
||||
"orders.sysout",
|
||||
"orders.sysoutv",
|
||||
"par",
|
||||
"parallelStream",
|
||||
"remove",
|
||||
"removeAll",
|
||||
"removeFirst",
|
||||
"removeIf",
|
||||
"removeLast",
|
||||
"replaceAll",
|
||||
"retainAll",
|
||||
"return",
|
||||
"reversed",
|
||||
"set",
|
||||
"size",
|
||||
"sort",
|
||||
"sout",
|
||||
"spliterator",
|
||||
"stream",
|
||||
"subList",
|
||||
"throw",
|
||||
"toArray",
|
||||
"toString",
|
||||
"try",
|
||||
"var",
|
||||
"wait",
|
||||
"while"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"completions": [
|
||||
"book.nnull",
|
||||
"book.opt",
|
||||
"book.syserr",
|
||||
"book.sysouf",
|
||||
"book.sysout",
|
||||
"book.sysoutv",
|
||||
"byCustomer",
|
||||
"cast",
|
||||
"else",
|
||||
"equals",
|
||||
"for",
|
||||
"fori",
|
||||
"getClass",
|
||||
"hashCode",
|
||||
"if",
|
||||
"largest",
|
||||
"nn",
|
||||
"not",
|
||||
"notify",
|
||||
"notifyAll",
|
||||
"null",
|
||||
"par",
|
||||
"place",
|
||||
"return",
|
||||
"revenueCents",
|
||||
"size",
|
||||
"sout",
|
||||
"throw",
|
||||
"toString",
|
||||
"try",
|
||||
"var",
|
||||
"wait",
|
||||
"while"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"declaration": [
|
||||
"jdt://contents/junit-jupiter-api-5.11.3.jar/org.junit.jupiter.api/Assertions.java#assertEquals"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"declaration": [
|
||||
"jdt://contents/java.base/java.util/AbstractList.java#AbstractList"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"declaration": [
|
||||
"jdt://contents/java.base/java.util/ArrayList.java#ArrayList"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"declaration": [
|
||||
"jdt://contents/java.base/java.util/Comparator.java#comparingLong"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"declaration": [
|
||||
"src/main/java/com/example/orders/OrderBook.java:28"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"problems": [
|
||||
"L33:error:Type mismatch: cannot convert from String to int"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"tools": {
|
||||
"jdtls": "1.63.0-202610191322",
|
||||
"java": "21.0.13.0.2 (Homebrew)"
|
||||
},
|
||||
"groups": {
|
||||
"problems": [
|
||||
"L34:warning:The value of the local variable used is not used"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача