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

..
Автор SHA1 Повідомлення Дата
l0ng-ai ee0952e32e test(evals): measure Java code intelligence as the user sees it
`pnpm eval` drives the production workbench in Chromium against a release
`openidea-server` and jdtls, and judges each scenario on what the user sees: time to
feedback, to the first result and to the final one, and the results against
`evals/java/golden/`. Scenarios cover navigation, usages, implementations, gutter
markers, code lenses, diagnostics, completion, quick documentation and rename, in
`fixtures/java-app` and a pinned spring-petclinic, warm and cold. The report
(`target/eval/report.json` / `.md`) names each failure's kind, a repro command and the
server commands in the slowest run's window; `--save-baseline` compares later runs.

- The frontend gets `data-*` hooks the scenarios read: usage / chooser rows carry their
  location, the usages popup its loading state and scope, a hint whether it is progress
  and what it answers.
- `smoke/tauriShim.ts`: the in-page Tauri runtime shared by the jsdom smoke tests and
  the evals; `perf/lib/bundle.ts` builds the perf bundle for both.
- `.github/tool-versions.env` pins the language-server versions for Language smoke and
  the evals.
- `.agents/skills/eval`: the measure → fix → prove loop for agents.
2026-11-11 22:39:59 +08:00
354 змінених файлів з 4449 додано та 12865 видалено

No files matched your search

+83
Переглянути файл
@@ -0,0 +1,83 @@
---
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 +1 @@
../../.agents/skills/eval
+14
Переглянути файл
@@ -0,0 +1,14 @@
# 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
+4 -11
Переглянути файл
@@ -24,6 +24,7 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
- ".github/tool-versions.env"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
@@ -38,16 +39,6 @@ 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:
@@ -61,6 +52,8 @@ 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.
@@ -96,7 +89,7 @@ jobs:
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "21" }
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
+1
Переглянути файл
@@ -40,6 +40,7 @@ CLAUDE.local.md
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
!.claude/skills/eval
# Local reference screenshots
pycharm-screenshots/
+1 -1
Переглянути файл
@@ -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",
+11 -3
Переглянути файл
@@ -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`). `fixtures/` — sample projects per language
for manual and automated testing.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
`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,9 +59,12 @@ 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.
`issue-fix` takes an issue from reproduction to one PR;
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
## Commands
@@ -81,6 +84,11 @@ 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.
Створено
-1
Переглянути файл
@@ -4618,7 +4618,6 @@ name = "ide-lsp"
version = "0.1.42"
dependencies = [
"dunce",
"encoding_rs",
"futures",
"globset",
"ide-core",
-102
Переглянути файл
@@ -1,102 +0,0 @@
//! A short-lived ACP session to learn models, effort levels and other config options
//! before the user's first task (Cursor and other ACP agents do not expose these in
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
use crate::{Shared, client_info};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
let root = root.clone();
move || launch.resolve_for_launch(&root)
})
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
let cwd_s = cwd.to_string_lossy().into_owned();
// No project MCP during discovery: Cursor can spend a long time spawning servers.
let new: Value = conn
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| e.to_string())?;
let mut options = config::options_of(&new);
config::add_client_options(&launch.id, &mut options);
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
let effort = levels_for_model_from_slice(&options);
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
(Some(model), Some(effort)) if !model.values.is_empty() => {
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
}
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
}
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
o.category.as_deref() == Some("thought_level")
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
}
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
let levels: Vec<_> = options
.iter()
.filter(|o| is_thought_level(o))
.cloned()
.map(|mut o| {
if o.category.as_deref() != Some("thought_level") {
o.category = Some("thought_level".into());
}
o
})
.collect();
(!levels.is_empty()).then_some(levels)
}
-4
Переглянути файл
@@ -14,7 +14,6 @@
//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and
//! permission requests get the v1 `toolCall`.
pub(crate) mod discover;
mod normalize;
use ide_jsonrpc::{Handler, Peer, RpcError, codes};
@@ -58,9 +57,6 @@ fn client_capabilities() -> Value {
"terminal": false,
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
"subagents": {},
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
// only when the client advertises this flag (undocumented Cursor ACP extension).
"_meta": { "parameterizedModelPicker": true },
})
}
+2 -74
Переглянути файл
@@ -93,81 +93,9 @@ fn the_negotiating_request_carries_both_versions() {
assert!(p["capabilities"].is_object());
assert_eq!(p["clientCapabilities"]["subagents"], json!({}));
assert_eq!(p["clientCapabilities"]["terminal"], false);
assert_eq!(
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
);
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
let mut expected = sent;
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
assert_eq!(expected, p["clientCapabilities"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent() {
#[derive(Default)]
struct DiscoveryAgent {
peer: std::sync::OnceLock<Peer>,
calls: parking_lot::Mutex<Vec<(String, Value)>>,
}
impl Handler for DiscoveryAgent {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push((method.to_owned(), params));
match method {
"initialize" => Ok(json!({
"protocolVersion": 1,
"agentCapabilities": { "loadSession": true }
})),
"session/new" => Ok(json!({
"sessionId": "discover",
"configOptions": [
{"id": "model", "category": "model", "type": "select", "currentValue": "m1",
"options": [{"value": "m1", "name": "One"}, {"value": "m2", "name": "Two"}]},
{"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
})),
_ => Ok(json!({})),
}
}
}
let agent = Arc::new(DiscoveryAgent::default());
let tmp = tempfile::tempdir().unwrap();
let agent_for_connector = agent.clone();
let connector: crate::Connector = Arc::new(move |_, _, client, _| {
let (ca, cb) = tokio::io::duplex(1 << 20);
let (car, caw) = tokio::io::split(ca);
let (cbr, cbw) = tokio::io::split(cb);
let agent_peer = Peer::connect_framed(cbr, cbw, agent_for_connector.clone(), Framing::Lines);
let _ = agent_for_connector.peer.set(agent_peer);
Ok(Peer::connect_framed(car, caw, client, Framing::Lines))
});
let mgr = crate::AgentManager::with_connector(
crate::AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["initialize", "session/new"]);
assert_eq!(calls[1].1["mcpServers"], json!([]));
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
assert_eq!(fast.category.as_deref(), Some("model_config"));
assert_eq!(fast.current.as_deref(), Some("false"));
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
let sent = &frames(TURN, "out")[0]["params"];
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
}
#[test]
+1 -24
Переглянути файл
@@ -107,15 +107,7 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
category: str_of(o, "category").or_else(|| {
matches!(id.as_str(), "model" | "mode")
.then(|| id.clone())
.or_else(|| {
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
.then(|| "thought_level".into())
})
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
}),
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
@@ -341,21 +333,6 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
"configOptions": [
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
}));
assert_eq!(c.len(), 2);
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].category.as_deref(), Some("model_config"));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
+30 -196
Переглянути файл
@@ -16,7 +16,6 @@ mod model;
mod native;
mod provider;
pub mod registry;
pub mod security;
mod skills;
mod timeline;
mod worktree;
@@ -40,9 +39,6 @@ use std::time::Duration;
/// Receives the events of one project's tasks.
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
/// Returns the active command execution security policy for the project.
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
/// Entry changes are batched for this long before they are sent.
const FLUSH_DELAY: Duration = Duration::from_millis(40);
/// Updates that arrive this late after `session/load` returns are still replayed history.
@@ -106,7 +102,6 @@ struct Shared {
runtime: tokio::runtime::Handle,
connector: Connector,
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
@@ -182,15 +177,9 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
if let Ok(found) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
@@ -209,8 +198,6 @@ impl Shared {
shared.remember_discovered(&spec.id, &options, &commands, &levels);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
@@ -233,17 +220,12 @@ impl Shared {
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
let models: Vec<&str> = known
known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
.flat_map(|o| &o.values)
.all(|v| known.thought_levels.contains_key(&v.value))
}
fn root_key(&self) -> String {
@@ -452,7 +434,6 @@ impl AgentManager {
runtime,
connector,
sink: RwLock::new(None),
security: RwLock::new(None),
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
@@ -496,11 +477,6 @@ impl AgentManager {
tasks
}
/// Sets the callback used to read the active command execution security policy.
pub fn set_security_hook(&self, hook: SecurityHook) {
*self.shared.security.write() = Some(hook);
}
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -525,9 +501,7 @@ impl AgentManager {
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs.clone();
drop(probes);
drop(prev);
*prev = specs;
list
}
@@ -548,30 +522,6 @@ impl AgentManager {
provider::set_builtin_env(&dir, id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(&self, input: CustomAgentInput) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::save_custom(&dir, &input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(&self, id: &str) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::remove_custom(&dir, id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(&self, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::import_acp(&dir, text)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -602,16 +552,10 @@ impl AgentManager {
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
{
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
if claude {
offered = self.shared.known_commands(&provider);
}
offered = self.shared.known_commands(&provider);
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -944,38 +888,14 @@ impl AgentManager {
}
o.clone()
};
let previous = target.current.clone();
// Cursor's `session/set_config_option` (Fast, effort) can take seconds. Show the
// choice first so the switch and slider move immediately.
apply_config_choice(&task, option, &value);
task.persist();
task.emit_task();
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
let reply: Value = match live.peer.request(method, params, None).await {
Ok(reply) => reply,
Err(e) => {
{
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
if let Some(prev) = previous.clone() {
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: prev });
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = previous.clone();
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
task.persist();
task.emit_task();
return Err(IdeError::internal(task.describe(e)));
}
};
let reply: Value =
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
if let Some(options) = reply.get("configOptions") {
config::merge_update(&mut task.state.lock().task.config, options);
model_options = true;
@@ -986,6 +906,8 @@ impl AgentManager {
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value);
}
@@ -1338,19 +1260,15 @@ 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 at = task.base();
let (cwd, base) = task.cwd_and_base();
let before = task.state.lock().before.clone();
blocking(move || at.changes(&before)).await
blocking(move || Ok(worktree::without_untouched(worktree::changes(&cwd, &base)?, &before))).await
}
/// Content of `path` (in the task's directory) when the task started.
pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult<Option<String>> {
let at = self.task(id)?.base();
blocking(move || match at.rev()? {
Some(rev) => worktree::file_at(at.cwd(), &path, &rev),
None => Ok(None),
})
.await
let (cwd, base) = self.task(id)?.cwd_and_base();
blocking(move || worktree::file_at(&cwd, &path, &base)).await
}
/// Rollback: puts the task's changed files among `rel_paths` (all with `None`) back as they
@@ -1360,17 +1278,16 @@ impl AgentManager {
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before rolling back its changes"));
}
let at = task.base();
let (cwd, base) = task.cwd_and_base();
let (before, saved) = {
let s = task.state.lock();
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
};
let restamped = blocking(move || {
let changes = at.changes(&before)?;
let rev = at.rev()?;
let changes = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before);
let mut restamped = Vec::new();
for change in changes.iter().filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path))) {
worktree::restore(at.cwd(), rev.as_deref(), change, &before, saved.as_deref())?;
worktree::restore(&cwd, &base, change, &before, saved.as_deref())?;
// Back to the user's own edits: not a change of the task any more.
if before.contains_key(&change.rel_path) {
restamped.push((change.rel_path.clone(), worktree::stamp(&change.path.0)));
@@ -1389,41 +1306,6 @@ impl AgentManager {
Ok(())
}
/// Accept: copies the task's changed files among `rel_paths` (all with `None`) from its
/// worktree into the local checkout, uncommitted, for the user to review and commit.
/// Files the checkout changed itself are left alone and reported. Only worktree tasks
/// have anything to accept.
pub async fn accept_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<AgentAcceptOutcome> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before accepting its changes"));
}
let (isolation, cwd, base, root, before) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.base_commit.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
(*s.before).clone(),
)
};
if isolation != Isolation::Worktree {
return Err(IdeError::invalid("the task works in the local checkout: there is nothing to accept"));
}
let Some(base) = base else {
return Err(IdeError::invalid("the task has no base revision to accept from"));
};
blocking(move || {
let wanted: Vec<AgentChange> = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before)
.into_iter()
.filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path)))
.collect();
worktree::accept(&root, &cwd, &base, &wanted)
})
.await
}
/// Stops every agent (project closed).
pub fn shutdown(&self) {
for task in self.tasks.read().values() {
@@ -1474,15 +1356,15 @@ impl Task {
})
}
fn base(&self) -> worktree::TaskBase {
fn cwd_and_base(&self) -> (PathBuf, String) {
let s = self.state.lock();
let cwd = s.task.cwd.0.clone();
match s.task.isolation {
Isolation::Local => worktree::TaskBase::Local { cwd, start: s.task.base_commit.clone() },
Isolation::Worktree => {
worktree::TaskBase::Worktree { cwd, base: s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into()) }
}
}
// In the project's own checkout the user commits too: compare against the current HEAD,
// as the Commit tool window does, so commits made since the task started drop out.
let base = match s.task.isolation {
Isolation::Local => None,
Isolation::Worktree => s.task.base_commit.clone(),
};
(s.task.cwd.0.clone(), base.unwrap_or_else(|| "HEAD".into()))
}
fn emit_task(&self) {
@@ -2242,44 +2124,6 @@ impl Task {
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
if let Some(hook) = self.shared.security.read().clone()
&& let Some(command) = security::extract_command(params)
{
match security::evaluate_command(&command, &hook()) {
security::PolicyDecision::Blocked(pattern) => {
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("Blocked by policy ({pattern})")),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
security::PolicyDecision::Allowed => {
if let Some(allow) = options
.iter()
.find(|o| o.kind == "allow_once")
.or_else(|| options.iter().find(|o| o.kind.starts_with("allow")))
.cloned()
{
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("{} (whitelisted)", allow.name)),
});
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
}
}
security::PolicyDecision::AskUser => {}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
@@ -2593,7 +2437,7 @@ fn with_details(message: String, data: Option<&Value>) -> String {
}
}
pub(crate) fn client_info() -> Value {
fn client_info() -> Value {
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
}
@@ -2652,16 +2496,6 @@ fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
})
}
fn apply_config_choice(task: &Task, option: &str, value: &str) {
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.to_owned() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value.to_owned());
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
-46
Переглянути файл
@@ -602,49 +602,3 @@ pub struct McpServerInput {
fn default_true() -> bool {
true
}
/// Payload for creating a custom ACP agent (always the ACP driver); the id
/// becomes the `<config>/agents/<id>.toml` file name.
#[derive(Clone, Debug, Deserialize, Type)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CustomAgentInput {
pub id: String,
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
/// What importing one entry of a JetBrains `acp.json` file did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AcpImportOutcome {
/// The `agent_servers` key the entry was imported from.
pub name: String,
/// The id its agent file would get (also when skipped).
pub id: String,
pub saved: bool,
/// Why the entry was skipped; empty when it was saved.
#[serde(default)]
pub reason: String,
}
/// What accepting a worktree task's changes copied into the local checkout.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptOutcome {
/// Relative paths copied into the checkout (uncommitted), including ones already identical.
pub accepted: Vec<String>,
/// Files left alone because the checkout has its own edits to them.
pub skipped: Vec<AgentAcceptSkipped>,
}
/// A file accept left alone, with why.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptSkipped {
pub path: String,
pub reason: String,
}
+1 -198
Переглянути файл
@@ -2,7 +2,7 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -278,127 +278,6 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
Ok(())
}
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
/// and an empty command. The id is also the agent file's name, so it must be
/// a safe file stem.
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
let id = validated_custom_id(&input.id)?;
if builtin_spec(&id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
}
if input.command.trim().is_empty() {
return Err(IdeError::invalid("the agent command must not be empty"));
}
let file = custom_dir.join(format!("{id}.toml"));
if file.try_exists()? {
if CustomAgent::read(&file).is_ok_and(|a| a.registry.is_some()) {
return Err(IdeError::invalid(format!(
"{id} is installed from the ACP Registry; uninstall it there to replace it"
)));
}
return Err(IdeError::invalid(format!("there is already a custom agent '{id}'")));
}
CustomAgent {
name: Some(input.name.clone()),
driver: Driver::Acp,
command: input.command.clone(),
args: input.args.clone(),
env: input.env.clone(),
registry: None,
}
.write(&file)?;
Ok(())
}
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
let id = validated_custom_id(id)?;
let id = id.as_str();
if builtin_spec(id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent and cannot be removed")));
}
let file = custom_dir.join(format!("{id}.toml"));
let agent =
CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("there is no custom agent '{id}'")))?;
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry; uninstall it there")));
}
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
/// agents, one outcome per entry; entries that collide, lack a command or
/// have no usable name are skipped with a reason.
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
#[derive(Deserialize)]
struct AcpJson {
#[serde(default)]
agent_servers: BTreeMap<String, AcpServer>,
}
#[derive(Deserialize)]
struct AcpServer {
#[serde(default)]
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
let parsed: AcpJson =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("not an acp.json file: {e}")))?;
let mut outcomes = Vec::with_capacity(parsed.agent_servers.len());
for (name, server) in parsed.agent_servers {
let id = slugify_id(&name);
let mut outcome = AcpImportOutcome { name, id: id.clone(), saved: false, reason: String::new() };
if id.is_empty() {
outcome.reason = "the name has no usable characters for an id".to_owned();
} else if builtin_spec(&id).is_some() {
outcome.reason = format!("{id} is a built-in agent");
} else if server.command.trim().is_empty() {
outcome.reason = "the entry has no command".to_owned();
} else if custom_dir.join(format!("{id}.toml")).try_exists()? {
outcome.reason = format!("there is already a custom agent '{id}'");
} else {
CustomAgent {
name: Some(outcome.name.clone()),
driver: Driver::Acp,
command: server.command,
args: server.args,
env: server.env,
registry: None,
}
.write(&custom_dir.join(format!("{id}.toml")))?;
outcome.saved = true;
}
outcomes.push(outcome);
}
Ok(outcomes)
}
/// A custom agent id doubles as a file stem: letters, digits, `-` and `_` only.
fn validated_custom_id(id: &str) -> IdeResult<String> {
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Ok(id.to_owned());
}
Err(IdeError::invalid(format!("'{id}' is not a usable agent id (letters, digits, '-' and '_' only)")))
}
/// Turns an `acp.json` server name into a file-stem id (`"My Agent!"` → `"my-agent"`).
fn slugify_id(name: &str) -> String {
let mut id = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
id.push(c.to_ascii_lowercase());
} else if !id.ends_with('-') {
id.push('-');
}
}
id.trim_matches('-').to_owned()
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -874,80 +753,4 @@ mod tests {
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
}
fn custom_input(id: &str) -> CustomAgentInput {
CustomAgentInput {
id: id.into(),
name: "Mine".into(),
command: "/opt/mine".into(),
args: vec!["acp".into()],
env: BTreeMap::from([("K".to_owned(), "v".to_owned())]),
}
}
#[test]
fn custom_agents_save_list_and_remove() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap();
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
assert_eq!(mine.source, AgentSource::Custom);
remove_custom(dir.path(), "mine").unwrap();
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), "mine").unwrap_err();
}
#[test]
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), &custom_input("")).unwrap_err();
let mut empty_command = custom_input("mine");
empty_command.command = " ".into();
save_custom(dir.path(), &empty_command).unwrap_err();
save_custom(dir.path(), &custom_input("mine")).unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), "claude").unwrap_err();
// An id that isn't a plain file stem never reaches outside the agents directory.
let outside = dir.path().join("outside.toml");
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir(&agents).unwrap();
remove_custom(&agents, "../outside").unwrap_err();
assert!(outside.exists());
std::fs::write(
dir.path().join("amp-acp.toml"),
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
)
.unwrap();
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), "amp-acp").unwrap_err();
}
#[test]
fn acp_json_imports_servers_and_skips_collisions() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("taken")).unwrap();
let outcomes = import_acp(
dir.path(),
r#"{"agent_servers": {
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
"taken": {"command": "/opt/taken"},
"claude": {"command": "/opt/claude"},
"!!!": {"command": "/opt/noname"},
"silent": {"args": []}
}}"#,
)
.unwrap();
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
assert_eq!(saved, ["my-agent"]);
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
assert_eq!(mine.source, AgentSource::Custom);
import_acp(dir.path(), "not json").unwrap_err();
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
}
}
-487
Переглянути файл
@@ -1,487 +0,0 @@
//! Command execution security policy (Whitelist / Blacklist) for coding agents.
use ide_core::settings::AgentSecuritySettings;
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicyDecision {
/// Command matched a blacklist pattern (`pattern`).
Blocked(String),
/// Command matched the whitelist and no blacklist pattern.
Allowed,
/// Neither matched; prompt the user as usual.
AskUser,
}
/// Extracts the shell command string from an ACP `session/request_permission` payload
/// when the request is for executing a command.
pub fn extract_command(params: &Value) -> Option<String> {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
return Some(cmd);
}
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let title =
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
return Some(cmd.to_owned());
}
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
&& !title.is_empty()
&& title != "Terminal"
&& title != "Run a command"
{
return Some(title.to_owned());
}
None
}
fn command_from_value(val: &Value) -> Option<String> {
match val {
Value::String(s) => {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
Value::Array(items) => {
let words: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
if words.is_empty() {
return None;
}
let cmd = match words.as_slice() {
[_, flag, script] if flag.starts_with('-') && flag.ends_with('c') => (*script).trim().to_owned(),
_ => words.join(" ").trim().to_owned(),
};
(!cmd.is_empty()).then_some(cmd)
}
_ => None,
}
}
/// Checks whether a single pattern matches `command` using prefix + glob rules:
/// - If `pattern` contains `*` or `?`, matches using glob wildcards (where `*` matches
/// any sequence of characters). A trailing ` *` in the pattern also matches the bare
/// command without arguments (e.g. `git status *` matches `git status`).
/// - Otherwise, matches when `command` equals `pattern` or starts with `pattern` followed
/// by whitespace (prefix match at word boundary).
pub fn matches_command(pattern: &str, command: &str) -> bool {
let pat = pattern.trim();
let cmd = command.trim();
if pat.is_empty() || cmd.is_empty() {
return false;
}
if pat.contains('*') || pat.contains('?') {
if let Some(stem) = pat.strip_suffix(" *")
&& cmd == stem.trim()
{
return true;
}
wildcard_match(pat.as_bytes(), cmd.as_bytes())
} else {
cmd == pat || cmd.strip_prefix(pat).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
}
fn wildcard_match(pat: &[u8], text: &[u8]) -> bool {
let (mut p, mut t) = (0, 0);
let (mut star_p, mut star_t) = (None, 0);
while t < text.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == b'*' {
star_p = Some(p);
p += 1;
star_t = t;
} else if let Some(sp) = star_p {
p = sp + 1;
star_t += 1;
t = star_t;
} else {
return false;
}
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
/// Matches an allowed command entry against a sub-command.
/// An allow entry is a whole command or a prefix only up to a word boundary:
/// e.g. `git status` matches `git status` and `git status -s`, but NOT `git statusx`.
/// Any accidental trailing `*` in the allow entry is stripped so `git status*` behaves
/// cleanly as a prefix at word boundaries instead of a glob wildcard.
pub fn matches_allow_entry(entry: &str, command: &str) -> bool {
let mut entry = entry.trim();
if let Some(stripped) = entry.strip_suffix('*') {
entry = stripped.trim_end();
}
let cmd = command.trim();
if entry.is_empty() || cmd.is_empty() {
return false;
}
cmd == entry || cmd.strip_prefix(entry).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
/// Checks whether a word is an environment variable assignment (`IDENTIFIER=...`).
fn is_env_assignment_word(word: &str) -> bool {
let Some((name, _)) = word.split_once('=') else {
return false;
};
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Returns true if the sub-command starts with an environment variable assignment,
/// e.g. `FOO=bar cmd` or `PORT=3000`.
fn has_leading_env_assignment(cmd: &str) -> bool {
let trimmed = cmd.trim_start();
let bytes = trimmed.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
}
i += 1;
continue;
}
if b == b'\'' || b == b'"' {
quote = Some(b);
i += 1;
continue;
}
if b.is_ascii_whitespace() {
break;
}
i += 1;
}
let first_word = &trimmed[..i];
is_env_assignment_word(first_word)
}
/// Checks whether `command` contains shell constructs that must never be auto-approved:
/// - Command substitution: `$(...)` or backticks
/// - Redirection: `>`, `>>`, `<`
/// - Here-docs: `<<`
/// - Subshells: `(...)`
/// - Leading environment assignments: `VAR=val cmd`
pub fn has_unsafe_shell_syntax(command: &str) -> bool {
let cmd = command.trim();
if cmd.is_empty() {
return false;
}
if has_leading_env_assignment(cmd) {
return true;
}
let bytes = cmd.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
i += 1;
continue;
}
// Inside double quotes: $(...) and `...` are evaluated by the shell.
if q == b'"' {
if b == b'`' {
return true;
}
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
return true;
}
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'`' => return true,
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => return true,
b'<' | b'>' => return true,
b'(' | b')' => return true,
_ => i += 1,
}
}
false
}
/// Splits a shell command into sub-commands separated by `;`, `&&`, `||`, `|`, or `\n`
/// (outside single/double quotes) so compound commands cannot bypass policy checks.
fn split_subcommands(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = quote {
if b == b'\\' && q == b'"' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
quote = None;
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'\\' if i + 1 < bytes.len() => {
i += 2;
}
b';' | b'\n' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
i += 1;
start = i;
}
b'&' | b'|' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
if i + 1 < bytes.len() && bytes[i + 1] == b {
i += 2;
} else {
i += 1;
}
start = i;
}
_ => i += 1,
}
}
let tail = command[start..].trim();
if !tail.is_empty() {
out.push(tail);
}
out
}
/// Evaluates `command` against `settings`:
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
/// 2. Whitelist: allowed_commands cannot auto-approve when empty.
/// 3. Every sub-command in a chain must be safe (no command substitution, redirection,
/// subshell, or env assignments) and match at least one `allowed_commands` entry
/// exactly or up to word boundary; otherwise falls back to [`PolicyDecision::AskUser`].
pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> PolicyDecision {
let cmd = command.trim();
if cmd.is_empty() {
return PolicyDecision::AskUser;
}
let subcommands = split_subcommands(cmd);
for pat in &settings.blocked_commands {
let pat = pat.trim();
if pat.is_empty() {
continue;
}
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
return PolicyDecision::Blocked(pat.to_owned());
}
}
if settings.allowed_commands.is_empty() || subcommands.is_empty() {
return PolicyDecision::AskUser;
}
let all_allowed = subcommands.iter().all(|sub| {
let s = sub.trim();
if s.is_empty() || has_unsafe_shell_syntax(s) {
return false;
}
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
});
if all_allowed {
return PolicyDecision::Allowed;
}
PolicyDecision::AskUser
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prefix_and_glob_matching() {
assert!(matches_command("git status", "git status"));
assert!(matches_command("git status", "git status -s"));
assert!(!matches_command("git status", "git statusbar"));
assert!(matches_command("git status*", "git status"));
assert!(matches_command("git status*", "git status --short"));
assert!(matches_command("npm test *", "npm test"));
assert!(matches_command("npm test *", "npm test unit"));
assert!(matches_command("rm -rf *", "rm -rf /tmp/foo"));
assert!(matches_command("rm -rf *", "rm -rf /"));
}
#[test]
fn blacklist_takes_precedence_and_catches_chained_commands() {
let settings = AgentSecuritySettings::default();
assert_eq!(evaluate_command("rm -rf /tmp/build", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command(":(){ :|:& };:", &settings), PolicyDecision::Blocked(":(){ :|:& };:".into()));
}
#[test]
fn allow_entry_matching_word_boundary() {
assert!(matches_allow_entry("git status", "git status"));
assert!(matches_allow_entry("git status", "git status -s"));
assert!(!matches_allow_entry("git status", "git statusbar"));
assert!(!matches_allow_entry("git status", "git statusx"));
// Trailing asterisk in allow entry normalizes cleanly to word boundary prefix
assert!(matches_allow_entry("git status*", "git status"));
assert!(matches_allow_entry("git status*", "git status --short"));
assert!(!matches_allow_entry("git status*", "git statusbar"));
assert!(matches_allow_entry("ls", "ls"));
assert!(matches_allow_entry("ls", "ls -la"));
assert!(!matches_allow_entry("ls", "lsof"));
assert!(matches_allow_entry("cargo check", "cargo check"));
assert!(matches_allow_entry("cargo check", "cargo check --workspace"));
assert!(!matches_allow_entry("cargo check", "cargo checkmate"));
}
#[test]
fn unsafe_shell_syntax_detection() {
// Command substitutions: $(...) and `...`
assert!(has_unsafe_shell_syntax("echo $(whoami)"));
assert!(has_unsafe_shell_syntax("echo \"user: $(whoami)\""));
assert!(has_unsafe_shell_syntax("echo `whoami`"));
assert!(has_unsafe_shell_syntax("echo \"`whoami`\""));
// Redirections: >, >>, <
assert!(has_unsafe_shell_syntax("git status > /tmp/out"));
assert!(has_unsafe_shell_syntax("git status >> /tmp/out"));
assert!(has_unsafe_shell_syntax("cat < /tmp/in"));
assert!(has_unsafe_shell_syntax("cat << 'EOF'"));
// Subshells: (...)
assert!(has_unsafe_shell_syntax("(cd /tmp && ls)"));
assert!(has_unsafe_shell_syntax("git status && (exit 1)"));
// Environment assignments: VAR=val ...
assert!(has_unsafe_shell_syntax("FOO=bar git status"));
assert!(has_unsafe_shell_syntax("PORT=3000 npm test"));
assert!(has_unsafe_shell_syntax("_SECRET=123 cargo test"));
assert!(has_unsafe_shell_syntax("A=\"b c\" git status"));
// Safe constructs with characters inside quotes or command flags
assert!(!has_unsafe_shell_syntax("git commit -m \"fix <bug> and (issue)\""));
assert!(!has_unsafe_shell_syntax("git commit -m 'fix <bug>'"));
assert!(!has_unsafe_shell_syntax("cargo test --test=unit"));
assert!(!has_unsafe_shell_syntax("git status -s"));
}
#[test]
fn default_settings_has_no_auto_approval_and_blocks_dangerous_defaults() {
let settings = AgentSecuritySettings::default();
assert!(settings.allowed_commands.is_empty(), "allowed_commands must start empty");
// Safe commands ask user by default
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("ls", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo check", &settings), PolicyDecision::AskUser);
// Blacklist patterns are still blocked by default
assert_eq!(evaluate_command("rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("rm -rf /tmp/test", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn whitelist_allows_safe_single_and_chained_commands_only() {
let settings = AgentSecuritySettings {
allowed_commands: vec!["git status".into(), "git diff".into(), "cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
};
assert_eq!(evaluate_command("git status -s", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git diff HEAD~1", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git status && cargo test", &settings), PolicyDecision::Allowed);
// Word boundary: git statusx must not match git status
assert_eq!(evaluate_command("git statusx", &settings), PolicyDecision::AskUser);
// Unsafe syntax (redirection, command substitution, env assignment) falls back to AskUser
assert_eq!(evaluate_command("git status > out.txt", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("git status $(whoami)", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("FOO=bar git status", &settings), PolicyDecision::AskUser);
// Chaining an unapproved command must still ask the user.
assert_eq!(evaluate_command("git status && curl https://example.com", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo publish", &settings), PolicyDecision::AskUser);
// Blacklist still takes precedence over whitelist
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn extracts_command_from_v1_v2_and_codex_payloads() {
let claude = json!({
"toolCall": {
"title": "git status",
"kind": "execute",
"rawInput": { "command": "git status --short" }
}
});
assert_eq!(extract_command(&claude).as_deref(), Some("git status --short"));
let codex = json!({
"toolCall": {
"title": "Run cargo test",
"kind": "execute",
"rawInput": { "command": ["/bin/zsh", "-lc", "cargo test --workspace"] }
}
});
assert_eq!(extract_command(&codex).as_deref(), Some("cargo test --workspace"));
let write = json!({
"toolCall": {
"title": "Write foo.txt",
"kind": "edit",
"rawInput": { "file_path": "foo.txt" }
}
});
assert_eq!(extract_command(&write), None);
}
}
-318
Переглянути файл
@@ -43,11 +43,6 @@ struct FakeAgent {
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Blocks `session/new` until discovery times out (see `acp::discover` test timeout).
hold_session_new: AtomicBool,
/// Sleeps this long at the start of `session/set_config_option`, after noting the time.
delay_config: Duration,
config_entered: Mutex<Option<std::time::Instant>>,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
@@ -162,9 +157,6 @@ impl Handler for FakeAgent {
)
}
"session/new" => {
if self.hold_session_new.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_secs(5));
}
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
@@ -198,10 +190,6 @@ impl Handler for FakeAgent {
}
}
"session/set_config_option" | "session/set_mode" => {
if method == "session/set_config_option" && !self.delay_config.is_zero() {
*self.config_entered.lock() = Some(std::time::Instant::now());
std::thread::sleep(self.delay_config);
}
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
@@ -2643,92 +2631,6 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.reload_providers();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(agent.calls.lock().is_empty(), "reload must not spawn cursor-agent");
}
#[tokio::test(flavor = "multi_thread")]
async fn composer_commands_discover_cursor_effort_and_fast() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![
json!({"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}),
json!({"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}),
]),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert!(calls.iter().any(|m| m.as_str() == "session/new"));
assert_eq!(calls.iter().filter(|m| m.as_str() == "initialize").count(), 1);
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert_eq!(cursor.config.iter().find(|o| o.id == "fast").unwrap().current.as_deref(), Some("false"));
assert!(
cursor
.thought_levels
.values()
.next()
.is_some_and(|levels| { levels.iter().any(|o| o.category.as_deref() == Some("thought_level")) })
);
}
#[tokio::test(flavor = "multi_thread")]
async fn config_choice_shows_before_the_agent_answers() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![json!({
"id": "fast", "category": "model_config", "type": "select", "currentValue": "true",
"options": [{"value": "true", "name": "On"}, {"value": "false", "name": "Off"}]
})]),
delay_config: Duration::from_millis(300),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let shown: Arc<Mutex<Option<std::time::Instant>>> = Arc::default();
let mark = shown.clone();
mgr.subscribe(Arc::new(move |e| {
if let AgentEvent::Task { task } = e
&& task.config.iter().any(|o| o.id == "fast" && o.current.as_deref() == Some("false"))
&& mark.lock().is_none()
{
*mark.lock() = Some(std::time::Instant::now());
}
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "fast", "false".into()).await.unwrap();
let shown = (*shown.lock()).expect("the switch moved");
let entered = (*agent.config_entered.lock()).expect("the agent received the change");
assert!(shown < entered, "the composer updates before the agent answers");
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_times_out_when_session_new_hangs() {
let agent = Arc::new(FakeAgent { hold_session_new: AtomicBool::new(true), ..Default::default() });
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let started = std::time::Instant::now();
mgr.commands("cursor", None).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(10), "discovery should time out quickly in tests");
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
@@ -2868,223 +2770,3 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
mgr.delete(&task.id, false).await.unwrap();
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
/// A git repository in a temporary folder, for local tasks whose checkout moves under them.
struct Checkout {
_tmp: tempfile::TempDir,
dir: PathBuf,
}
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:?}"
);
}
+9 -224
Переглянути файл
@@ -117,136 +117,14 @@ 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) {
out.push(added(s.path, s.rel_path));
let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32);
out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 });
}
}
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(out)
}
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)>;
@@ -298,11 +176,11 @@ pub(crate) fn save_before(cwd: &Path, before: &BTreeMap<String, Stamp>, dir: &Pa
}
/// Rolls `change` back: to the copy saved when the task started when the file had changes
/// then (`before`, `saved` holding the copies), otherwise to `rev` (removed when `rev` lacks
/// it, or when there is no `rev`: the repository had no commits).
/// then (`before`, `saved` holding the copies), otherwise to `base` (removed when `base`
/// lacks it).
pub(crate) fn restore(
cwd: &Path,
rev: Option<&str>,
base: &str,
change: &AgentChange,
before: &BTreeMap<String, Stamp>,
saved: Option<&Path>,
@@ -320,13 +198,10 @@ pub(crate) fn restore(
})?;
Some(std::fs::read(copy)?)
}
None => match rev {
Some(rev) => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(rev))?
}
None => None,
},
None => {
let repo = Repo::discover(cwd)?.ok_or_else(|| IdeError::invalid("not in a git repository"))?;
repo.file_bytes_at(path, Some(base))?
}
};
match content {
Some(bytes) => {
@@ -531,94 +406,4 @@ mod tests {
assert!(remove(&repo, &tmp.path().join("wt"), &elsewhere).is_err());
assert!(elsewhere.join("keep.txt").exists());
}
fn accept_repo() -> (tempfile::TempDir, PathBuf, Created) {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap();
std::fs::write(repo.join("gone.txt"), "bye\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap();
(tmp, repo, created)
}
fn listed(cwd: &Path, base: &str) -> Vec<AgentChange> {
without_untouched(changes(cwd, base).unwrap(), &BTreeMap::new())
}
#[test]
fn accept_copies_new_modified_and_deleted_files_into_the_checkout() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
std::fs::write(created.path.join("new.txt"), "x\n").unwrap();
std::fs::remove_file(created.path.join("gone.txt")).unwrap();
let outcome =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert!(outcome.skipped.is_empty());
assert_eq!(outcome.accepted, ["a.txt", "gone.txt", "new.txt"]);
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nTWO\n");
assert_eq!(std::fs::read_to_string(repo.join("new.txt")).unwrap(), "x\n");
assert!(!repo.join("gone.txt").exists());
// Accepting again changes nothing: the checkout already matches.
let again =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert!(again.skipped.is_empty());
assert_eq!(again.accepted, ["a.txt", "gone.txt", "new.txt"]);
}
#[test]
fn a_local_task_goes_back_to_its_start_unless_the_user_committed_on_top_of_it() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
let local = |start: Option<&str>| TaskBase::Local { cwd: repo.clone(), start: start.map(str::to_owned) };
let head = || head_of(&repo).unwrap().1;
let commit = |name: &str| {
std::fs::write(repo.join(name), "x\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", name]);
head_of(&repo).unwrap().1
};
assert_eq!(local(None).rev().unwrap(), None, "no commits yet");
let first = commit("a.txt");
assert_eq!(local(None).rev().unwrap(), Some(first.clone()), "an imported session: HEAD");
let start = commit("b.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "HEAD where it started");
let ahead = commit("c.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(ahead), "committed on top: HEAD");
git(&repo, &["reset", "-q", "--soft", &first]);
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "moved back: the start");
git(&repo, &["checkout", "-q", "-b", "side"]);
commit("d.txt");
assert_eq!(local(Some(&start)).rev().unwrap(), Some(start.clone()), "another branch: the start");
let gone = "0123456789abcdef0123456789abcdef01234567";
assert_eq!(local(Some(gone)).rev().unwrap(), Some(head()), "an unknown start: HEAD");
let worktree = TaskBase::Worktree { cwd: repo.clone(), base: start.clone() };
assert_eq!(worktree.rev().unwrap(), Some(start));
}
#[test]
fn accept_leaves_files_the_checkout_changed_itself() {
let (_tmp, repo, created) = accept_repo();
std::fs::write(created.path.join("a.txt"), "one\nTWO\n").unwrap();
std::fs::write(created.path.join("gone.txt"), "bye\nbye\n").unwrap();
std::fs::write(repo.join("a.txt"), "one\nMINE\n").unwrap();
let outcome =
accept(&repo, &created.path, &created.base_commit, &listed(&created.path, &created.base_commit)).unwrap();
assert_eq!(outcome.accepted, ["gone.txt"]);
assert_eq!(outcome.skipped.len(), 1);
assert_eq!(outcome.skipped[0].path, "a.txt");
assert!(!outcome.skipped[0].reason.is_empty());
// The user's own edit survives; the other file comes over.
assert_eq!(std::fs::read_to_string(repo.join("a.txt")).unwrap(), "one\nMINE\n");
assert_eq!(std::fs::read_to_string(repo.join("gone.txt")).unwrap(), "bye\nbye\n");
}
}
+1 -31
Переглянути файл
@@ -266,11 +266,6 @@ pub(super) fn maven_steps(
BuildKind::Rebuild => &["clean", "test-compile"],
BuildKind::Artifacts => &["package", "-DskipTests"],
};
// The IDE's compile builds are not coverage runs. JaCoCo's offline `instrument`
// rewrites target/classes in `process-classes`; `jacoco.skip` prevents that in this
// build. It does not restore classes instrumented by an earlier build: Rebuild's
// `clean` removes those, while an incremental Build can still reuse them.
let skip_jacoco = matches!(kind, BuildKind::Build | BuildKind::Rebuild);
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
if tops.is_empty() {
return Err(IdeError::not_found("no Maven project found"));
@@ -283,9 +278,6 @@ pub(super) fn maven_steps(
Format::Maven,
);
s.args.extend(p.options.iter().cloned());
if skip_jacoco {
s.args.push("-Djacoco.skip=true".into());
}
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
@@ -1049,22 +1041,6 @@ mod tests {
std::fs::write(p, text).unwrap();
}
#[test]
fn maven_compile_steps_skip_jacoco_but_artifacts_do_not() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("demo");
write(&root, "pom.xml", "<project><artifactId>demo</artifactId></project>");
let model = crate::load(&root, &tmp.path().join("cache"), None);
for (kind, expected) in [(BuildKind::Build, true), (BuildKind::Rebuild, true), (BuildKind::Artifacts, false)] {
let plan = plan(&root, &model, kind, None, None).unwrap();
assert_eq!(
plan.steps[0].args.iter().any(|arg| arg == "-Djacoco.skip=true"),
expected,
"{kind:?}: {plan:?}"
);
}
}
#[test]
fn plans_per_build_system() {
let tmp = tempfile::tempdir().unwrap();
@@ -1081,10 +1057,7 @@ mod tests {
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(
p.steps[0].args,
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "test-compile"]
);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(
p.steps[0].args,
@@ -1092,7 +1065,6 @@ mod tests {
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-Djacoco.skip=true",
"-pl",
"core",
"-am",
@@ -1381,7 +1353,6 @@ mod tests {
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
@@ -1398,7 +1369,6 @@ mod tests {
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
-1
Переглянути файл
@@ -536,7 +536,6 @@ mod tests {
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"-Djacoco.skip=true",
"test-compile"
]
);
-13
Переглянути файл
@@ -275,11 +275,6 @@ pub enum DebugEvent {
},
/// The session's capabilities changed after it started: fetch them again.
Capabilities,
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
/// `variables`; empty means all) is stale and has to be fetched again.
Invalidated {
areas: Vec<String>,
},
JvmBackend {
state: JvmBackendState,
},
@@ -335,12 +330,4 @@ mod tests {
);
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
}
#[test]
fn invalidated_event_json_shape() {
assert_eq!(
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
json!({"kind": "invalidated", "areas": ["variables"]})
);
}
}
+2 -10
Переглянути файл
@@ -12,16 +12,11 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// `p` as the UI sees it: `C:\a\b`, `c:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
/// `p` as the UI sees it: `C:\a\b` and `\\?\C:\a\b` become `C:/a/b`.
pub fn to_ipc(p: &Path) -> String {
#[cfg(windows)]
{
let mut s = dunce::simplified(p).to_string_lossy().replace('\\', "/");
// The UI compares path strings; a drive letter is case-insensitive.
if s.as_bytes().get(1) == Some(&b':') {
s[..1].make_ascii_uppercase();
}
s
dunce::simplified(p).to_string_lossy().replace('\\', "/")
}
#[cfg(not(windows))]
{
@@ -149,8 +144,5 @@ mod tests {
assert_eq!(to_ipc(Path::new(r"\\?\C:\a\b")), "C:/a/b");
assert_eq!(to_ipc(&Path::new("C:/a").join("b")), "C:/a/b");
assert_eq!(serde_json::to_string(&IpcPath::from(r"C:\a\b")).unwrap(), r#""C:/a/b""#);
assert_eq!(to_ipc(Path::new(r"c:\a\b")), "C:/a/b");
assert_eq!(to_ipc(Path::new(r"\\?\d:\a")), "D:/a");
assert_eq!(to_ipc(Path::new(r"\\server\share\a")), "//server/share/a");
}
}
-3
Переглянути файл
@@ -63,8 +63,6 @@ pub struct Settings {
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Tools › Agents: Command Execution Policy.
pub agent_security: AgentSecuritySettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
@@ -413,7 +411,6 @@ impl Default for Settings {
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
agent_security: AgentSecuritySettings::default(),
plugins: BTreeMap::new(),
}
}
+2 -35
Переглянути файл
@@ -138,11 +138,8 @@ impl WorkspaceEdit {
continue;
}
*expected = expected.or(version);
// Repeats within one entry are intended (LSP inserts at one position
// apply in order); only an alias entry repeats what is already merged.
let merged = own.len();
for edit in edits {
if !own[..merged].contains(&edit) {
if !own.contains(&edit) {
own.push(edit);
}
}
@@ -280,40 +277,10 @@ pub struct OutlineItem {
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
pub type MatchIndices = Vec<u32>;
#[cfg(test)]
#[cfg(all(test, unix))]
mod workspace_edit_tests {
use super::*;
#[test]
fn repeated_edits_within_one_entry_survive_but_repeats_across_entries_do_not() {
let path = PathBuf::from("missing-dir/main.ts");
let at = |line, text: &str| TextEdit {
range: Range::new(Pos::new(line, 0), Pos::new(line, 0)),
new_text: text.into(),
};
let entry = |edits: Vec<TextEdit>| FileEdit::Edit { path: path.clone().into(), edits, version: None };
let (nl, brace, other) = (at(0, "\n"), at(1, "}"), at(2, "x"));
for (entries, expected) in [
(vec![vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![brace.clone(), nl.clone(), brace.clone()]], vec![brace.clone(), nl.clone(), brace.clone()]),
(vec![vec![nl.clone(), nl.clone()], vec![nl.clone(), nl.clone()]], vec![nl.clone(), nl.clone()]),
(vec![vec![nl.clone()], vec![nl.clone(), other.clone()]], vec![nl.clone(), other.clone()]),
(
vec![vec![nl.clone()], vec![other.clone(), other.clone()]],
vec![nl.clone(), other.clone(), other.clone()],
),
] {
let mut workspace =
WorkspaceEdit { files: entries.clone().into_iter().map(entry).collect(), ..Default::default() };
workspace.deduplicate();
assert_eq!(workspace.files, [entry(expected.clone())], "{entries:?}");
let once = workspace.clone();
workspace.deduplicate();
assert_eq!(workspace, once, "{entries:?}");
}
}
#[cfg(unix)]
#[test]
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
let dir = tempfile::tempdir().unwrap();
-27
Переглянути файл
@@ -412,30 +412,3 @@ impl Default for ExternalTool {
}
}
}
/// Tools › Agents: Command Execution Policy (whitelist and blacklist patterns).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSecuritySettings {
/// Commands or patterns (`git status*`, `pwd`) automatically approved without prompting.
pub allowed_commands: Vec<String>,
/// Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected.
pub blocked_commands: Vec<String>,
}
impl Default for AgentSecuritySettings {
fn default() -> Self {
Self {
allowed_commands: Vec::new(),
blocked_commands: vec![
"rm -rf *".into(),
"rm -rf /".into(),
"mkfs*".into(),
"dd *".into(),
":(){ :|:& };:".into(),
"shutdown*".into(),
"reboot*".into(),
],
}
}
}
-7
Переглянути файл
@@ -72,13 +72,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Inside the language server: reached through `debugServer`, never started.
"kotlin-lsp" => cfg(
"kotlin-lsp",
&[],
"tcp",
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
+1 -1
Переглянути файл
@@ -29,7 +29,7 @@ pub(crate) use scan::watch_for_attached_jvm;
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+5 -9
Переглянути файл
@@ -9,15 +9,11 @@ use std::{
pub const ADAPTER: &str = "kotlin-debug-adapter";
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
pub fn is_kotlin(adapter: &str) -> bool {
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
}
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
/// exist, so only its link holds resumes back until the attach is done.
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
/// until the attach is done.
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: true }
}
pub struct SourceMirror {
-147
Переглянути файл
@@ -1,147 +0,0 @@
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
//! the `debugServer` port it opens once the project is imported.
//!
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
//! after a step over a suspension point, loses every later stop at that breakpoint; while
//! stepping, breakpoints hit by other coroutines are skipped.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
pub const ADAPTER: &str = "kotlin-lsp";
/// The `adapterID` its debug server expects in `initialize`.
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
/// anything else, including a URI naming another host.
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
let rest = path.strip_prefix("file:")?;
let path = match rest.strip_prefix("//") {
Some(authority) => {
let slash = authority.find('/')?;
if !matches!(&authority[..slash], "" | "localhost") {
return None;
}
&authority[slash..]
}
None => rest,
};
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
// `/C:/p/App.kt` on Windows.
let decoded = match decoded.strip_prefix('/') {
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
_ => &decoded,
};
Some(PathBuf::from(decoded))
}
pub(crate) fn frame_name(name: &str) -> &str {
name.strip_prefix("at ").unwrap_or(name)
}
/// What kotlin-lsp's DAP server does unlike other adapters.
///
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
#[derive(Default)]
pub(crate) struct Quirks {
step_pending: AtomicBool,
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
threads: Mutex<(u64, HashSet<i64>)>,
}
impl Quirks {
/// Before `next`, `stepIn`, `stepOut` or a forced step.
pub fn step_sent(&self) {
self.step_pending.store(true, Ordering::Release);
}
/// Before `continue` or run to cursor, or after a step request failed.
pub fn resume_sent(&self) {
self.step_pending.store(false, Ordering::Release);
}
/// The reason to report for a `stopped` event.
pub fn stop_reason(&self, reason: String) -> String {
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
if stepped && reason == "breakpoint" { "step".into() } else { reason }
}
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
/// during that stop. A reply from an older stop changes nothing.
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
let mut threads = self.threads.lock();
if stop > threads.0 {
*threads = (stop, ids.into_iter().collect());
} else if stop == threads.0 {
threads.1.extend(ids);
}
}
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
let threads = self.threads.lock();
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn file_uris_become_paths() {
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
assert_eq!(source_path("/p/x.kt"), None);
}
#[test]
fn frame_names_lose_the_at_prefix() {
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
assert_eq!(frame_name("main"), "main");
}
#[test]
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
let quirks = Quirks::default();
quirks.step_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
quirks.step_sent();
quirks.resume_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
}
#[test]
fn threads_are_checked_against_the_current_stop() {
let quirks = Quirks::default();
quirks.saw_threads(3, [18]);
assert!(quirks.check_thread(3, true, 18).is_ok());
assert!(quirks.check_thread(3, true, 6).is_err());
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
assert!(quirks.check_thread(3, false, 6).is_ok());
}
#[test]
fn an_older_threads_reply_does_not_change_a_newer_stop() {
let quirks = Quirks::default();
quirks.saw_threads(4, [2]);
quirks.saw_threads(3, [1]);
assert!(quirks.check_thread(4, true, 1).is_err());
quirks.saw_threads(4, [7]);
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
quirks.saw_threads(5, [9]);
assert!(quirks.check_thread(5, true, 2).is_err());
}
}
+19 -55
Переглянути файл
@@ -15,7 +15,6 @@ pub mod connection;
pub mod jdwp;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -611,19 +610,9 @@ fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
.collect()
}
/// A step request's outcome; a failed one leaves no step pending, so the next stop keeps its reason.
fn step_settled(session: &Session, sent: Result<Value, DapError>) -> Result<Value, DapError> {
if sent.is_err()
&& let Some(quirks) = session.kotlin_lsp_quirks()
{
quirks.resume_sent();
}
sent
}
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (in DAP mode a
/// temp-file name, `__debug_bin<digits>`; on Windows with `.exe` before the digits) and
/// removes it on a graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// Delve compiles the debuggee to `__debug_bin` in the session's cwd (on Windows
/// `__debug_bin.exe` plus `__debug_bin.exe<digits>` in DAP mode) and removes it on a
/// graceful exit — a shutdown it doesn't always reach, so the files pile up. Sweep
/// the leftovers once the session is down. Runs after `session_closed`, so Delve
/// has already exited or been killed and holds no file locks; a leftover of a
/// still-building run is removed on that run's own shutdown.
@@ -632,10 +621,14 @@ fn remove_delve_binaries(cwd: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// `__debug_bin`, then `.exe` on Windows, then DAP mode's temp-file digits.
// Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin").map(|s| s.strip_prefix(".exe").unwrap_or(s));
let leftover = suffix.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()));
// Windows writes `__debug_bin.exe` (and `.exe<digits>` in DAP mode); other
// platforms keep the bare name. Suffixes like `.go` are user files.
let suffix = name.strip_prefix("__debug_bin");
let leftover = suffix.is_some_and(|s| {
s.is_empty()
|| s == ".exe"
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
});
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
continue;
}
@@ -988,7 +981,6 @@ impl DebugManager {
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
let caps = s
.conn
.request_timeout(
@@ -996,7 +988,7 @@ impl DebugManager {
json!({
"clientID": "openidea",
"clientName": "OpenIDEA",
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
"adapterID": s.adapter,
"locale": "en",
"linesStartAt1": true,
"columnsStartAt1": true,
@@ -1006,8 +998,7 @@ impl DebugManager {
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
// kotlin-lsp sends `invalidated` either way.
"supportsInvalidatedEvent": is_kotlin_lsp,
"supportsInvalidatedEvent": false,
"supportsMemoryReferences": false,
"supportsArgsCanBeInterpretedByShell": false,
}),
@@ -1017,7 +1008,7 @@ impl DebugManager {
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
@@ -1041,7 +1032,7 @@ impl DebugManager {
jvm_memory::find_attached_jvm(s, port, finder).await;
}
}
if kotlin::is_kotlin(&s.adapter) {
if s.adapter == kotlin::ADAPTER {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
@@ -1053,7 +1044,7 @@ impl DebugManager {
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
jvm_memory::watch_link(s, &link);
*s.jdwp_link.lock() = Some(link);
@@ -1390,8 +1381,6 @@ impl DebugManager {
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let session = self.session(s)?;
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
let stop = session.suspended.ticket();
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
let threads: Vec<ThreadInfo> = body
.get("threads")
@@ -1405,15 +1394,11 @@ impl DebugManager {
.collect()
})
.unwrap_or_default();
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
}
Ok(threads)
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
let body = session
.conn
.request_timeout(
@@ -1422,15 +1407,11 @@ impl DebugManager {
REQUEST_TIMEOUT,
)
.await?;
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
if kotlin_lsp {
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
@@ -1542,10 +1523,6 @@ impl DebugManager {
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.resume_sent();
}
let ticket = session.suspended.ticket();
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// DAP: a response without the flag continued every thread.
@@ -1590,13 +1567,8 @@ impl DebugManager {
StepKind::Out => "stepOut",
};
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
step_settled(&session, sent)?;
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// A step runs its thread; other threads stopped by their own events stay stopped.
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
@@ -1615,8 +1587,6 @@ impl DebugManager {
force: bool,
) -> IdeResult<()> {
let session = self.session(s)?;
// Before touching breakpoints: a refused `continue` would leave them replaced.
session.check_thread(thread_id)?;
if force {
self.suspend_breakpoints(&session, Some(&path)).await?;
}
@@ -1691,7 +1661,6 @@ impl DebugManager {
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
self.suspend_breakpoints(&session, None).await?;
self.step(s, thread_id, StepKind::Over).await
}
@@ -1699,16 +1668,11 @@ impl DebugManager {
/// Step into a specific call of the current line (Smart Step Into).
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
let sent = session
session
.conn
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
.await;
step_settled(&session, sent)?;
.await?;
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
+1 -34
Переглянути файл
@@ -3,7 +3,6 @@
use crate::adapter::AdapterSpec;
use crate::connection::{Connection, DapError, Incoming};
use crate::kotlin_lsp;
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
use ide_core::event::DebugEvent;
#[cfg(unix)]
@@ -104,8 +103,6 @@ pub(crate) struct Session {
pub jvm_located: watch::Sender<bool>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
pub(crate) quirks: kotlin_lsp::Quirks,
}
/// Which threads of the program are suspended: each from its `stopped` event until a
@@ -255,19 +252,6 @@ impl Session {
jvm_located: watch::channel(false).0,
kotlin_mirror: Mutex::new(None),
jvm_memory: tokio::sync::Mutex::default(),
quirks: kotlin_lsp::Quirks::default(),
}
}
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
}
/// Call before sending a request for thread `id`.
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
match self.kotlin_lsp_quirks() {
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
None => Ok(()),
}
}
@@ -299,11 +283,6 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if self.adapter == kotlin_lsp::ADAPTER
&& let Some(path) = kotlin_lsp::source_path(path)
{
return Some(path);
}
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
@@ -718,11 +697,7 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
"stopped" => {
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
s.suspended.stopped(int("threadId"), all);
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
if let Some(quirks) = s.kotlin_lsp_quirks() {
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
reason = quirks.stop_reason(reason);
}
let reason = str_("reason").unwrap_or_else(|| "pause".into());
mgr.restore_breakpoints(s).await;
s.emit(DebugEvent::Stopped {
thread_id: int("threadId").unwrap_or(0),
@@ -824,14 +799,6 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
change_type: crate::hot_code_replace_type(body.get("changeType")),
message: str_("message").unwrap_or_default(),
}),
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
"invalidated" => s.emit(DebugEvent::Invalidated {
areas: body
.get("areas")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+6 -291
Переглянути файл
@@ -126,22 +126,8 @@ struct Script {
continued_after_stop: bool,
/// Close the connection when a `pause` request arrives (the adapter dies).
hang_up_on_pause: bool,
/// Close the connection at the first request after the first `stopped` event.
hang_up_after_stop: bool,
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
continue_reply: Option<Value>,
/// Answer `continue` and stop at a breakpoint again, instead of letting the program exit.
stop_after_continue: bool,
/// The `reason` of the stop a `next` ends with (`step` when unset; kotlin-lsp says `breakpoint`).
step_reason: Option<&'static str>,
/// Refuse `next`, then stop at a breakpoint (one the program hit meanwhile).
refuse_next: bool,
/// Send `invalidated { areas: ["variables"] }` right after the first `stopped` event.
invalidated_after_stop: bool,
/// On `threads`: stop again on thread 2 first, then answer with thread 1 of the earlier stop.
stop_during_threads: bool,
/// Name of the first stack frame (`main` when unset).
frame_name: Option<&'static str>,
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
delay_initialize: Option<Duration>,
initialized_delay: Duration,
@@ -170,14 +156,7 @@ impl Default for Script {
hold_set_breakpoints: 0,
continued_after_stop: false,
hang_up_on_pause: false,
hang_up_after_stop: false,
continue_reply: None,
stop_after_continue: false,
step_reason: None,
refuse_next: false,
invalidated_after_stop: false,
stop_during_threads: false,
frame_name: None,
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
@@ -223,7 +202,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
macro_rules! send {
($v:expr) => {{
@@ -244,9 +222,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if msg["type"] == "response" {
continue;
}
if script.hang_up_after_stop && stopped {
break;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => {
if let Some(delay) = script.delay_initialize {
@@ -345,18 +320,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}}));
}
"next" if script.refuse_next => {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "next", "success": false, "message": "refused"})
);
// Lets the client handle the refusal before the stop.
tokio::time::sleep(Duration::from_millis(50)).await;
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1}}));
}
"next" => {
ok!(msg, Value::Null);
let reason = script.step_reason.unwrap_or("step");
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
}
"setVariable" => {
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
@@ -392,12 +358,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
stopped = true;
if script.invalidated_after_stop {
send!(
json!({"type": "event", "event": "invalidated", "body": {"areas": ["variables"], "threadId": 1}})
);
}
if script.continued_after_stop {
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
}
@@ -405,19 +365,11 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
);
}
"threads" if script.stop_during_threads => {
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 2, "allThreadsStopped": true}})
);
// Lets the client handle the stop before it reads the reply.
tokio::time::sleep(Duration::from_millis(50)).await;
ok!(msg, json!({"threads": [{"id": 1, "name": "\"main\"@1"}]}));
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": script.frame_name.unwrap_or("main"), "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -441,12 +393,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
]})
),
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
"continue" if script.stop_after_continue => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
}
"continue" => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
@@ -1472,7 +1418,6 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
indent: None,
ui_mode: None,
framework_ui_modes: Vec::new(),
framework_files: Vec::new(),
formatter: None,
scratch_template: None,
tree_sitter: None,
@@ -2096,20 +2041,20 @@ async fn stop_kills_the_program_started_by_the_reported_launcher() {
fn delve_leftovers_are_removed_without_touching_other_files() {
let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
std::fs::write(dir.join(name), b"bin").unwrap();
}
// Lookalikes that are not Delve's own binaries stay: sources, a future
// in-progress build, and a directory that happens to share the name.
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go"] {
std::fs::write(dir.join(name), b"keep").unwrap();
}
std::fs::create_dir(dir.join("__debug_bin.dir")).unwrap();
remove_delve_binaries(&dir);
for name in ["__debug_bin", "__debug_bin833767175", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
for name in ["__debug_bin", "__debug_bin.exe", "__debug_bin.exe3323301925"] {
assert!(!dir.join(name).exists(), "{name} should be gone");
}
for name in ["__debug_bin.go", "__debug_bin.go.exe", "__debug_bin12.go", "main.go", "__debug_bin.dir"] {
for name in ["__debug_bin.go", "__debug_bin.go.exe", "main.go", "__debug_bin.dir"] {
assert!(dir.join(name).exists(), "{name} should stay");
}
std::fs::remove_dir_all(&dir).unwrap();
@@ -2456,13 +2401,6 @@ fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConf
}
}
#[test]
fn link_options_hold_only_for_fwcd() {
assert!(kotlin::link_options(kotlin::ADAPTER).hold_resumes);
assert!(!kotlin::link_options(kotlin_lsp::ADAPTER).hold_resumes);
assert!(!kotlin::link_options("x").hold_resumes);
}
#[tokio::test]
async fn kotlin_session_installs_link_inspector() {
let h = harness(Script::default());
@@ -2534,229 +2472,6 @@ async fn closing_the_session_drops_the_link() {
assert!(session.jvm_inspector.lock().is_none());
}
/// kotlin-lsp's debugger, reached through the language server's `debugServer` port (the fake ignores it).
fn kotlin_lsp_attach(project: &std::path::Path) -> LaunchConfig {
LaunchConfig {
adapter: kotlin_lsp::ADAPTER.into(),
request: "attach".into(),
cwd: Some(project.to_owned().into()),
extra_json: Some(
json!({"debugServer": 4711, "hostName": "127.0.0.1", "port": 5005, "projectRoot": project}).to_string(),
),
..config()
}
}
#[tokio::test]
async fn kotlin_lsp_initializes_as_intellij_jvm() {
let project = tempfile::tempdir().unwrap();
for (config, adapter_id, invalidated) in [
(kotlin_lsp_attach(project.path()), "intellij_jvm", true),
(kotlin_attach(project.path(), "127.0.0.1", 5005), "kotlin-debug-adapter", false),
] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
let init = &h.request("initialize")["arguments"];
assert_eq!(init["adapterID"], adapter_id);
assert_eq!(init["supportsInvalidatedEvent"], invalidated, "{adapter_id}");
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn kotlin_lsp_gets_real_paths_and_its_link_holds_nothing() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], json!(path));
let sent = &h.request("attach")["arguments"];
assert_eq!(sent["projectRoot"], json!(project.path()));
assert!(sent.get("debugServer").is_none());
assert_eq!(sent["hostName"], "127.0.0.1");
let link_port = sent["port"].as_u64().unwrap();
assert!(link_port != 0 && link_port != 5005);
let session = h.manager.session(id).unwrap();
assert!(session.kotlin_mirror.lock().is_none());
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
assert_eq!(session.attach_target.get(), Some(&("127.0.0.1".to_owned(), 5005)));
assert!(!kotlin::link_options(&session.adapter).hold_resumes);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_waits_for_the_configuration_done_reply() {
let project = tempfile::tempdir().unwrap();
// fwcd never answers it (`kotlin_configures_without_configuration_done_response`).
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let fwcd = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005));
let id = tokio::time::timeout(Duration::from_secs(2), fwcd).await.unwrap().unwrap();
h.manager.stop(id).await.unwrap();
tokio::time::pause();
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let started = tokio::time::Instant::now();
let err = h.start(kotlin_lsp_attach(project.path())).await.unwrap_err();
assert!(started.elapsed() >= REQUEST_TIMEOUT, "{:?}", started.elapsed());
assert!(err.message.contains("configurationDone"), "{err:?}");
assert!(h.manager.sessions(None).is_empty());
}
#[tokio::test]
async fn kotlin_lsp_sessions_have_the_memory_tab() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn losing_the_kotlin_lsp_connection_ends_the_session() {
let h = harness(Script { hang_up_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let session = h.manager.session(id).unwrap();
assert!(session.jdwp_link.lock().is_some());
let _ = session.conn.request_timeout("threads", Value::Null, Duration::from_secs(2)).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty());
assert!(session.jdwp_link.lock().is_none());
assert!(session.jvm_inspector.lock().is_none());
}
/// The reasons of the first `count` stops, once that many arrived.
async fn stop_reasons(h: &Harness, count: usize) -> Vec<String> {
for _ in 0..200 {
let reasons: Vec<String> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Stopped { reason, .. }, .. } => Some(reason.clone()),
_ => None,
})
.collect();
if reasons.len() >= count {
return reasons;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("{count} stops not received; got {:?}", h.events.lock());
}
#[tokio::test]
async fn a_step_stop_is_reported_as_a_step() {
let project = tempfile::tempdir().unwrap();
for (config, reason) in [(kotlin_lsp_attach(project.path()), "step"), (config(), "breakpoint")] {
let h = harness(Script { step_reason: Some("breakpoint"), ..Script::default() });
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", reason]);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_breakpoint_after_continue_stays_a_breakpoint() {
let h = harness(Script { step_reason: Some("breakpoint"), stop_after_continue: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
stop_reasons(&h, 2).await;
// The fake ends `stepIn` with no stop: the `continue` after it decides the next one.
h.manager.step(id, 1, StepKind::Into).await.unwrap();
h.manager.resume(id, 1).await.unwrap();
assert_eq!(stop_reasons(&h, 3).await, ["breakpoint", "step", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_breakpoint_after_a_refused_step_stays_a_breakpoint() {
let h = harness(Script { refuse_next: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(h.manager.step(id, 1, StepKind::Over).await.is_err());
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_thread_from_an_earlier_stop_is_refused() {
let project = tempfile::tempdir().unwrap();
for (config, kotlin_lsp) in [(kotlin_lsp_attach(project.path()), true), (config(), false)] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(h.manager.threads(id).await.unwrap().len(), 1);
let started = tokio::time::Instant::now();
let stale = h.manager.stack_trace(id, 99).await;
let asked =
|command: &str| h.log.lock().iter().any(|m| m["command"] == command && m["arguments"]["threadId"] == 99);
if kotlin_lsp {
assert!(stale.is_err() && started.elapsed() < Duration::from_millis(100), "{stale:?}");
assert!(h.manager.step(id, 99, StepKind::Over).await.is_err());
assert!(h.manager.resume(id, 99).await.is_err());
assert!(!asked("stackTrace") && !asked("next") && !asked("continue"));
} else {
assert!(stale.is_ok() && asked("stackTrace"), "{stale:?}");
}
assert!(h.manager.stack_trace(id, 1).await.is_ok());
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_threads_reply_from_before_a_stop_does_not_vouch_for_its_ids() {
let h = harness(Script { stop_during_threads: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let threads = h.manager.threads(id).await.unwrap();
// M3 maps kotlin-lsp's threads by these names.
assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "\"main\"@1"));
stop_reasons(&h, 2).await;
assert!(h.manager.stack_trace(id, 1).await.is_err());
assert!(h.manager.stack_trace(id, 2).await.is_ok());
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_frames_get_real_paths() {
let project = tempfile::tempdir().unwrap();
let at = "at com.x.AppKt.main(App.kt:13)";
for (config, path, name) in [
(kotlin_lsp_attach(project.path()), "/p/App.kt", "com.x.AppKt.main(App.kt:13)"),
(java_config("launch"), "file:///p/App.kt", at),
] {
let script = Script { source_path: Some("file:///p/App.kt".into()), frame_name: Some(at), ..Script::default() };
let h = harness(script);
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().map(|p| p.0.clone()), Some(PathBuf::from(path)));
assert_eq!(frames[0].name, name);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn invalidated_events_reach_the_client() {
let h = harness(Script { invalidated_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
match h.wait_event(|e| matches!(e, DebugEvent::Invalidated { .. })).await {
DebugEvent::Invalidated { areas } => assert_eq!(areas, ["variables"]),
other => panic!("{other:?}"),
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
+26 -320
Переглянути файл
@@ -9,14 +9,6 @@ use std::path::{Path, PathBuf};
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
pub const OVERRIDE_FILE: &str = "local-data-dir.txt";
/// A pending move of the local data to a newly chosen directory, carried out
/// at the next start (before any project opens), like File › Invalidate Caches.
const MIGRATION_FILE: &str = "local-data-migration.json";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
@@ -25,203 +17,6 @@ pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
/// The directory to actually start with: the environment override, else a valid
/// custom directory from Settings, else the OS default. A stored but unusable
/// custom directory is an error, never a silent fallback.
pub fn startup_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
startup_resolve(std::env::var_os(LOCAL_DATA_ENV), stored_override(identifier), dirs::data_local_dir(), identifier)
}
fn startup_resolve(
var: Option<OsString>,
stored: Option<PathBuf>,
os: Option<PathBuf>,
identifier: &str,
) -> Result<PathBuf, String> {
if var.as_ref().is_some_and(|v| !v.is_empty()) {
return resolve_local(var, os, identifier);
}
let Some(root) = stored else { return resolve_local(None, os, identifier) };
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, "custom local data directory")
}
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
pub fn stored_override(identifier: &str) -> Option<PathBuf> {
stored_override_in(&override_path(identifier)?)
}
fn stored_override_in(path: &Path) -> Option<PathBuf> {
let text = fs::read_to_string(path).ok()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
/// The OS default local data directory (what clearing the custom directory
/// returns to), ignoring any stored override. The environment still applies.
pub fn default_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
local_data_dir(identifier)
}
/// Stores (`Some`) or clears (`None`) the custom local-data root, returning the
/// directory that takes effect after a restart. A pending custom migration to
/// anywhere else is dropped: only the caller schedules the matching one.
pub fn set_override(identifier: &str, root: Option<&Path>) -> Result<PathBuf, String> {
let Some(path) = override_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
set_override_in(&path, &migration_path(identifier), root, identifier)
}
fn set_override_in(
path: &Path,
migration: &Option<PathBuf>,
root: Option<&Path>,
identifier: &str,
) -> Result<PathBuf, String> {
if let Some(migration) = migration {
let _ = fs::remove_file(migration);
}
let Some(root) = root else {
if let Err(error) = fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
return Err(format!("cannot clear the custom local data directory: {error}"));
}
return local_data_dir(identifier);
};
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
let dir = resolve_root(root.to_path_buf(), identifier, "custom local data directory")?;
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot store the custom local data directory: {error}"));
}
if let Err(error) = fs::write(path, format!("{}\n", root.display())) {
return Err(format!("cannot store the custom local data directory: {error}"));
}
Ok(dir)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CustomMigration {
from: PathBuf,
to: PathBuf,
}
/// Schedules a move of the local data from `from` to `to` at the next start.
/// Returns false when there is nothing to move. The caller restarts the IDE.
pub fn schedule_custom_migration(identifier: &str, from: &Path, to: &Path) -> Result<bool, String> {
let Some(path) = migration_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
schedule_custom_migration_in(&path, from, to)
}
fn schedule_custom_migration_in(path: &Path, from: &Path, to: &Path) -> Result<bool, String> {
if from == to {
return Ok(false);
}
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot schedule the data move: {error}"));
}
let json = serde_json::to_vec(&CustomMigration { from: from.into(), to: to.into() })
.map_err(|error| format!("cannot schedule the data move: {error}"))?;
if let Err(error) = fs::write(path, json) {
return Err(format!("cannot schedule the data move: {error}"));
}
Ok(true)
}
/// Carries out a scheduled custom migration aimed at `local` (call at start-up,
/// before projects open). Entries that appear in the meantime are never
/// overwritten; a partially moved tree is retried at the next start.
pub fn apply_custom_migration(identifier: &str, local: &Path) {
let Some(path) = migration_path(identifier) else { return };
apply_custom_migration_in(&path, local);
}
fn apply_custom_migration_in(path: &Path, local: &Path) {
let Ok(bytes) = fs::read(path) else { return };
let Ok(job) = serde_json::from_slice::<CustomMigration>(&bytes) else {
let _ = fs::remove_file(path);
return;
};
if job.to != local || job.from == local {
let _ = fs::remove_file(path);
return;
}
move_entries(&job.from, local, &custom_entries(&job.from));
let leftover = custom_entries(&job.from).into_iter().any(|entry| job.from.join(entry).exists());
if !leftover {
let _ = fs::remove_file(path);
}
}
/// Entries of a local-data tree that move to a newly chosen directory.
/// Non-relocatable tool installs (virtual environments with absolute interpreter
/// paths) stay behind and are reinstalled on demand, as in `migrate_entries`.
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());
@@ -230,14 +25,10 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, LOCAL_DATA_ENV)
}
fn resolve_root(root: PathBuf, identifier: &str, what: &str) -> Result<PathBuf, String> {
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -286,11 +77,6 @@ pub fn migrate(data: &Path, local: &Path) {
}
fn migrate_entries(data: &Path, local: &Path) {
move_entries(data, local, &local_entries(data));
}
/// The rebuildable entries of the legacy roaming tree that move to Local.
fn local_entries(data: &Path) -> Vec<PathBuf> {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
@@ -332,7 +118,31 @@ fn local_entries(data: &Path) -> Vec<PathBuf> {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
entries
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
@@ -787,108 +597,4 @@ mod tests {
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
#[test]
fn custom_override_round_trips_and_validates() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("local-data-dir.txt");
let migration = tmp.path().join("local-data-migration.json");
let id = "ai.test.openidea";
assert_eq!(stored_override_in(&file), None);
let root = tmp.path().join("custom-root");
let dir = set_override_in(&file, &Some(migration.clone()), Some(&root), id).unwrap();
assert_eq!(dir, root.join(id));
assert!(dir.is_dir());
assert_eq!(stored_override_in(&file), Some(root.clone()));
assert_eq!(startup_resolve(None, stored_override_in(&file), Some(tmp.path().join("os")), id).unwrap(), dir);
// A relative root is rejected before anything is written.
let before = fs::read_to_string(&file).unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(Path::new("relative")), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
assert_eq!(fs::read_to_string(&file).unwrap(), before);
// An unwritable root (an existing file) is rejected too.
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "").unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(&blocker), id).unwrap_err();
assert!(error.contains("cannot write"), "{error}");
// Clearing removes the file and returns the default.
let dir = set_override_in(&file, &Some(migration), None, id).unwrap();
assert!(!file.exists());
assert_eq!(dir, local_data_dir(id).unwrap());
assert_eq!(stored_override_in(&file), None);
}
#[test]
fn startup_prefers_the_environment_then_the_override() {
let tmp = tempfile::tempdir().unwrap();
let os = tmp.path().join("os");
let custom = tmp.path().join("custom");
let env = tmp.path().join("env");
let id = "ai.test.openidea";
let error = startup_resolve(None, Some(PathBuf::from("relative")), Some(os.clone()), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
// The environment wins over a stored override.
let dir = startup_resolve(Some(env.clone().into()), Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, env.join(id));
// Without the environment the override wins over the OS default.
let dir = startup_resolve(None, Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, custom.join(id));
// And without either the OS default applies.
let dir = startup_resolve(None, None, Some(os.clone()), id).unwrap();
assert_eq!(dir, os.join(id));
}
#[test]
fn custom_migration_moves_the_tree_and_clears_itself() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("local-data-migration.json");
let from = tmp.path().join("old-local");
let to = tmp.path().join("new-local");
fs::create_dir_all(from.join("index/p1")).unwrap();
fs::write(from.join("index/p1/symbols"), "index").unwrap();
fs::create_dir_all(from.join("cache/p1")).unwrap();
fs::write(from.join("cache/p1/build"), "cache").unwrap();
fs::create_dir_all(from.join("tools/demo/bin")).unwrap();
fs::write(from.join("tools/demo/bin/demo.cmd"), "payload").unwrap();
fs::write(from.join("history"), "roaming stays").unwrap();
// Nothing to move: no marker is written.
assert!(!schedule_custom_migration_in(&marker, &from, &from).unwrap());
assert!(!marker.exists());
assert!(schedule_custom_migration_in(&marker, &from, &to).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert_eq!(fs::read_to_string(to.join("index/p1/symbols")).unwrap(), "index");
assert_eq!(fs::read_to_string(to.join("cache/p1/build")).unwrap(), "cache");
assert!(to.join("tools/demo/bin/demo.cmd").exists());
assert!(!from.join("index").exists());
// Roaming content is not local data and stays behind.
assert_eq!(fs::read_to_string(from.join("history")).unwrap(), "roaming stays");
// A migration aimed elsewhere is dropped without touching anything.
fs::create_dir_all(from.join("lsp")).unwrap();
assert!(schedule_custom_migration_in(&marker, &from, &tmp.path().join("elsewhere")).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert!(from.join("lsp").exists());
assert!(!to.join("lsp").exists());
}
#[test]
fn custom_entries_cover_logs_and_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:?}");
}
}
}
+2 -36
Переглянути файл
@@ -5,9 +5,8 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
NewAgentTask,
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -45,24 +44,6 @@ pub fn set_env(
host.project(window)?.agents.set_builtin_env(&id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(host: &Host, window: &str, input: CustomAgentInput) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.save_custom_agent(input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(host: &Host, window: &str, id: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.remove_custom_agent(&id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(host: &Host, window: &str, text: String) -> IdeResult<Vec<AcpImportOutcome>> {
host.require_trust(window)?;
host.project(window)?.agents.import_acp_agents(&text)
}
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
@@ -316,18 +297,6 @@ pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Op
host.project(window)?.agents.rollback_changes(&task, paths).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
/// local checkout, uncommitted; files the checkout changed itself are left alone and reported.
pub async fn accept_changes(
host: &Host,
window: &str,
task: String,
paths: Option<Vec<String>>,
) -> IdeResult<AgentAcceptOutcome> {
host.require_trust(window)?;
host.project(window)?.agents.accept_changes(&task, paths).await
}
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
@@ -480,9 +449,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_rollback_changes", |c| async move {
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
d.add("agent_accept_changes", |c| async move {
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
-4
Переглянути файл
@@ -1,6 +1,3 @@
mod kotlin;
#[cfg(test)]
mod kotlin_tests;
mod output;
use crate::Host;
@@ -44,7 +41,6 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
})
.await?;
}
config = kotlin::choose_kotlin_debugger(host, window, config).await?;
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
-415
Переглянути файл
@@ -1,415 +0,0 @@
//! Kotlin debugging: kotlin-lsp's debugger when the project's kotlin-lsp can serve it, else
//! kotlin-debug-adapter with a notice saying why.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::task::CancelHandle;
use ide_core::{CancellationToken, IdeEvent, IdeResult, SharedSink, TaskId};
use ide_dap::LaunchConfig;
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
pub(super) const KOTLIN_IMPORT_WAIT: Duration = Duration::from_secs(120);
/// A ready kotlin-lsp answers well within this; only a longer wait shows a task.
const TASK_DELAY: Duration = Duration::from_millis(500);
// Catalogued as written (`{detail}` replaced at runtime): the text catalog test counts these
// literals as the use of its entries.
const TASK_TITLE: &str = "Waiting for kotlin-lsp to import the project";
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
const NOT_CONFIGURED: &str = "kotlin-lsp is not the Kotlin language server of this project.";
const NOT_INSTALLED: &str = "kotlin-lsp is not installed.";
const NOT_STARTED: &str = "kotlin-lsp did not start: {detail}";
const STILL_IMPORTING: &str =
"kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.";
const IMPORT_FAILED: &str = "kotlin-lsp could not import the project.";
const FAILED: &str = "kotlin-lsp could not start its debugger: {detail}";
/// `config` for kotlin-lsp's debug server on `port`; the rest of the launch stays as it is.
pub(super) fn use_kotlin_lsp(mut config: LaunchConfig, port: u16) -> IdeResult<LaunchConfig> {
let mut extra = extra_object(&config)?;
extra.insert("debugServer".into(), port.into());
config.adapter = ide_dap::kotlin_lsp::ADAPTER.into();
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
Ok(config)
}
/// The dedupe key of `reason` and the notice body (English, translated by the UI's catalog).
pub(super) fn fallback_notice(reason: &KotlinDebugUnavailable) -> (&'static str, String) {
match reason {
KotlinDebugUnavailable::NotConfigured => ("notConfigured", NOT_CONFIGURED.into()),
KotlinDebugUnavailable::NotInstalled => ("notInstalled", NOT_INSTALLED.into()),
KotlinDebugUnavailable::NotStarted(detail) => ("notStarted", NOT_STARTED.replace("{detail}", detail)),
KotlinDebugUnavailable::StillImporting => ("stillImporting", STILL_IMPORTING.into()),
KotlinDebugUnavailable::ImportFailed => ("importFailed", IMPORT_FAILED.into()),
KotlinDebugUnavailable::Failed(detail) => ("failed", FAILED.replace("{detail}", detail)),
}
}
/// A kotlin-debug-adapter launch becomes a kotlin-lsp one when the project's kotlin-lsp has
/// imported the module and started its debug server; otherwise it stays on
/// kotlin-debug-adapter and the window is told why (once per reason).
pub(super) async fn choose_kotlin_debugger(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<LaunchConfig> {
if config.adapter == ide_dap::kotlin_lsp::ADAPTER {
require_debug_server(&config)?;
return Ok(config);
}
if config.adapter != ide_dap::kotlin::ADAPTER {
return Ok(config);
}
let Ok(project) = host.project(window) else {
tracing::info!(
"debugging Kotlin with kotlin-debug-adapter: {}",
fallback_notice(&KotlinDebugUnavailable::NotConfigured).1
);
return Ok(config);
};
let path = debug_path(&config).unwrap_or_else(|| project.root.clone());
let lsp = project.lsp.clone();
let chosen = wait_for_debug_server(&project.sink, |cancel| async move {
lsp.kotlin_debug_server(&path, KOTLIN_IMPORT_WAIT, &cancel).await
})
.await;
match chosen {
Ok(server) => use_kotlin_lsp(config, server.port),
Err(reason) => {
report_fallback(&project.kotlin_debug_notices, &project.sink, &reason);
Ok(config)
}
}
}
/// kotlin-lsp is only reached through the port its language server opened; without one
/// ide-dap would try to spawn a `kotlin-lsp` adapter that does not exist.
fn require_debug_server(config: &LaunchConfig) -> IdeResult<()> {
match extra_object(config)?.get("debugServer") {
None | Some(serde_json::Value::Null) => Err(ide_core::IdeError::invalid(
"A kotlin-lsp debug configuration needs \"debugServer\": the port of kotlin-lsp's debug server. \
Use kotlin-debug-adapter to let the IDE start it.",
)),
Some(_) => Ok(()),
}
}
fn extra_object(config: &LaunchConfig) -> IdeResult<serde_json::Map<String, serde_json::Value>> {
match config.extra_json.as_deref().map(serde_json::from_str).transpose() {
Ok(None) => Ok(serde_json::Map::new()),
Ok(Some(serde_json::Value::Object(extra))) => Ok(extra),
Ok(Some(other)) => Err(ide_core::IdeError::invalid(format!("launch arguments are not an object: {other}"))),
Err(e) => Err(ide_core::IdeError::invalid(e.to_string())),
}
}
/// The module to debug: the launch's `projectRoot`, else its working directory.
fn debug_path(config: &LaunchConfig) -> Option<PathBuf> {
let extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok());
extra
.as_ref()
.and_then(|e| e.get("projectRoot")?.as_str())
.filter(|root| !root.is_empty())
.map(PathBuf::from)
.or_else(|| config.cwd.as_deref().map(PathBuf::from))
}
/// Runs `debug_server` with a token the user can cancel; a wait longer than [`TASK_DELAY`]
/// shows as a cancellable task, which ends however the wait does (also when dropped).
async fn wait_for_debug_server<F, Fut>(
sink: &SharedSink,
debug_server: F,
) -> Result<KotlinDebugServer, KotlinDebugUnavailable>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = Result<KotlinDebugServer, KotlinDebugUnavailable>>,
{
let cancel = CancellationToken::new();
let id = TaskId::next();
let handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let wait = debug_server(cancel);
tokio::pin!(wait);
if let Ok(answer) = tokio::time::timeout(TASK_DELAY, &mut wait).await {
return answer;
}
let _task = WaitTask::start(sink.clone(), id, handle);
wait.await
}
/// The visible import wait; dropping it ends the task.
struct WaitTask {
sink: SharedSink,
id: TaskId,
_cancel: CancelHandle,
}
impl WaitTask {
fn start(sink: SharedSink, id: TaskId, cancel: CancelHandle) -> Self {
let task = Self { sink, id, _cancel: cancel };
task.emit(false);
task
}
fn emit(&self, done: bool) {
self.sink.emit(IdeEvent::Task {
id: self.id,
title: TASK_TITLE.into(),
message: None,
fraction: None,
done,
cancellable: !done,
});
}
}
impl Drop for WaitTask {
fn drop(&mut self) {
self.emit(true);
}
}
/// Logs every fallback; notifies the window once per reason.
fn report_fallback(notices: &Mutex<HashSet<&'static str>>, sink: &SharedSink, reason: &KotlinDebugUnavailable) {
let (key, body) = fallback_notice(reason);
tracing::info!("debugging Kotlin with kotlin-debug-adapter: {body}");
if notices.lock().insert(key) {
sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, title: NOTICE_TITLE.into(), body: Some(body) });
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Arc;
fn launch(adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some("/cwd".into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn extra(config: &LaunchConfig) -> serde_json::Value {
serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap()
}
fn reasons() -> Vec<KotlinDebugUnavailable> {
vec![
KotlinDebugUnavailable::NotConfigured,
KotlinDebugUnavailable::NotInstalled,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::ImportFailed,
KotlinDebugUnavailable::Failed("boom".into()),
]
}
fn recording_sink() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let recorded = events.clone();
(Arc::new(move |event| recorded.lock().push(event)), events)
}
/// `(done, cancellable)` of each task event, with the task id.
fn tasks(events: &Mutex<Vec<IdeEvent>>) -> Vec<(ide_core::TaskId, String, bool, bool)> {
events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { id, title, done, cancellable, .. } => Some((*id, title.clone(), *done, *cancellable)),
_ => None,
})
.collect()
}
fn server() -> KotlinDebugServer {
KotlinDebugServer { port: 4711, root: PathBuf::from("/p") }
}
#[test]
fn kotlin_lsp_takes_the_debug_server_port() {
let config = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"mainClass": "a.AppKt", "projectRoot": "/p"}));
let c = use_kotlin_lsp(config, 4711).unwrap();
assert_eq!(c.adapter, ide_dap::kotlin_lsp::ADAPTER);
let args = extra(&c);
assert_eq!(args["debugServer"], 4711);
assert_eq!(args["mainClass"], "a.AppKt");
assert_eq!(args["projectRoot"], "/p");
assert!(args.get("__adapterJavaHome").is_none());
assert_eq!(
(c.name.as_str(), c.request.as_str(), c.cwd.as_deref()),
("AppKt", "launch", Some(Path::new("/cwd")))
);
let mut bare = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert_eq!(extra(&use_kotlin_lsp(bare, 4711).unwrap())["debugServer"], 4711);
}
#[test]
fn every_fallback_reason_has_a_catalogued_text() {
let catalogs: [(&str, serde_json::Value); 2] = [
("en", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/en.json")).unwrap()),
("zh", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/zh.json")).unwrap()),
];
let templates = [NOT_CONFIGURED, NOT_INSTALLED, NOT_STARTED, STILL_IMPORTING, IMPORT_FAILED, FAILED];
for reason in reasons() {
let (_, body) = fallback_notice(&reason);
let detail = match &reason {
KotlinDebugUnavailable::NotStarted(d) | KotlinDebugUnavailable::Failed(d) => d.as_str(),
_ => "",
};
assert!(
templates.iter().any(|t| t.replace("{detail}", detail) == body),
"{reason:?}: {body:?} is not built from a catalogued template"
);
}
for text in templates.iter().chain([&NOTICE_TITLE, &TASK_TITLE]) {
for (locale, catalog) in &catalogs {
let translated = catalog[*text].as_str().unwrap_or_else(|| panic!("{locale}.json lacks {text:?}"));
if *locale == "zh" {
assert_ne!(translated, *text, "zh.json leaves {text:?} untranslated");
}
}
}
}
#[test]
fn fallback_reasons_have_distinct_keys() {
let keys: HashSet<_> = reasons().iter().map(|r| fallback_notice(r).0).collect();
assert_eq!(keys.len(), reasons().len());
// The detail does not make another reason.
assert_eq!(
fallback_notice(&KotlinDebugUnavailable::Failed("a".into())).0,
fallback_notice(&KotlinDebugUnavailable::Failed("b".into())).0
);
}
#[test]
fn a_kotlin_lsp_config_without_a_debug_server_is_refused() {
let refused = require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}))).unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
let mut bare = launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert!(require_debug_server(&bare).is_err());
assert!(
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": null})))
.is_err()
);
for port in [serde_json::json!(4711), serde_json::json!("10.0.0.5:4711")] {
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": port})))
.unwrap();
}
}
#[test]
fn the_module_is_the_project_root_else_the_working_directory() {
let with_root = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"projectRoot": "/p/app"}));
assert_eq!(debug_path(&with_root), Some(PathBuf::from("/p/app")));
assert_eq!(debug_path(&launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}))), Some(PathBuf::from("/cwd")));
let mut neither = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
neither.cwd = None;
assert_eq!(debug_path(&neither), None);
}
#[tokio::test(start_paused = true)]
async fn a_quick_answer_shows_no_task() {
let (sink, events) = recording_sink();
assert_eq!(wait_for_debug_server(&sink, |_| async { Ok(server()) }).await, Ok(server()));
assert!(events.lock().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_longer_wait_shows_a_task_that_ends_on_every_outcome() {
for outcome in [Ok(server()), Err(KotlinDebugUnavailable::ImportFailed)] {
let (sink, events) = recording_sink();
let answer = outcome.clone();
let result = wait_for_debug_server(&sink, |_| async move {
tokio::time::sleep(Duration::from_secs(5)).await;
answer
})
.await;
assert_eq!(result, outcome);
let tasks = tasks(&events);
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert_eq!(tasks[0].0, tasks[1].0);
assert_eq!(tasks[0].1, TASK_TITLE);
assert_eq!((tasks[0].2, tasks[0].3), (false, true));
assert_eq!((tasks[1].2, tasks[1].3), (true, false));
}
}
#[tokio::test(start_paused = true)]
async fn cancelling_the_task_stops_the_wait() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |cancel| async move {
cancel.cancelled().await;
Err(KotlinDebugUnavailable::StillImporting)
})
.await
});
tokio::time::sleep(Duration::from_secs(1)).await;
let id = tasks(&events)[0].0;
assert!(ide_core::task::cancel(id));
assert_eq!(wait.await.unwrap(), Err(KotlinDebugUnavailable::StillImporting));
assert_eq!(tasks(&events).last().map(|t| t.2), Some(true));
assert!(!ide_core::task::cancel(id), "an ended wait is no longer cancellable");
}
#[tokio::test(start_paused = true)]
async fn a_dropped_wait_ends_its_task() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |_| std::future::pending::<Result<KotlinDebugServer, _>>()).await
});
tokio::time::sleep(Duration::from_secs(1)).await;
wait.abort();
let _ = wait.await;
assert_eq!(tasks(&events).iter().map(|t| t.2).collect::<Vec<_>>(), [false, true]);
}
#[test]
fn the_notice_shows_once_per_reason() {
let (sink, events) = recording_sink();
let notices = Mutex::new(HashSet::new());
for reason in [
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::NotStarted("crashed".into()),
] {
report_fallback(&notices, &sink, &reason);
}
let notes: Vec<_> = events
.lock()
.iter()
.map(|e| match e {
IdeEvent::Notify { level, title, body } => (*level, title.clone(), body.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
notes,
[
(NotifyLevel::Info, NOTICE_TITLE.to_string(), Some(STILL_IMPORTING.to_string())),
(
NotifyLevel::Info,
NOTICE_TITLE.to_string(),
Some("kotlin-lsp did not start: needs JDK 21".to_string())
),
]
);
}
}
-123
Переглянути файл
@@ -1,123 +0,0 @@
//! Kotlin's debugger choice through a real `Host` and project: the fallback notice and the
//! refusal of a kotlin-lsp configuration without its debug server.
use super::*;
use crate::HostConfig;
use ide_core::IdeEvent;
use ide_core::event::NotifyLevel;
use ide_lsp::KotlinDebugUnavailable;
use ide_workspace::SettingsScope;
use parking_lot::Mutex;
use std::sync::Arc;
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
struct Fixture {
host: Host,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
_temp: tempfile::TempDir,
}
impl Fixture {
/// A trusted Gradle project opened with these global settings; kotlin-debug-adapter is a
/// missing executable, so a session fails right after the debugger is chosen.
async fn new(settings: &[(&str, serde_json::Value)]) -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("settings.gradle.kts"), "rootProject.name = \"app\"\n").unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |event| events.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let missing = base.join("missing").join("kotlin-debug-adapter");
host.global_settings
.set(SettingsScope::Global, "tools", serde_json::json!({"kotlin-debug-adapter": {"path": missing}}))
.unwrap();
for (key, value) in settings {
host.global_settings.set(SettingsScope::Global, key, value.clone()).unwrap();
}
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
Self { host, root, events, _temp: temp }
}
fn launch(&self, adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(self.root.clone().into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn kotlin_launch(&self) -> LaunchConfig {
let extra = serde_json::json!({"mainClass": "com.example.AppKt", "projectRoot": self.root});
self.launch(ide_dap::kotlin::ADAPTER, extra)
}
/// `(level, body)` of each fallback notice.
fn notices(&self) -> Vec<(NotifyLevel, Option<String>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { level, title, body } if title == NOTICE_TITLE => Some((*level, body.clone())),
_ => None,
})
.collect()
}
}
/// Two Debugs, each failing only at kotlin-debug-adapter's missing executable.
async fn debug_twice(f: &Fixture) {
for _ in 0..2 {
let error = debug_start(&f.host, "main", f.kotlin_launch()).await.unwrap_err();
assert!(!error.message.contains("Restricted Mode"), "{}", error.message);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kotlin_lsp_turned_off_gives_one_notice_over_two_debugs() {
let f = Fixture::new(&[("languageServers", serde_json::json!({"kotlin-lsp": {"enabled": false}}))]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotConfigured);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_missing_kotlin_lsp_gives_one_notice_over_two_debugs() {
let missing = serde_json::json!({"kotlin-lsp": {"command": "/nonexistent/kotlin-lsp"}});
let f = Fixture::new(&[("languageServers", missing)]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotInstalled);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_kotlin_lsp_config_without_its_debug_server_is_refused() {
let f = Fixture::new(&[]).await;
let config = f.launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"mainClass": "com.example.AppKt"}));
let refused = debug_start(&f.host, "main", config).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
assert!(f.notices().is_empty());
}
-30
Переглянути файл
@@ -169,25 +169,6 @@ pub async fn remote_rename(state: &Host, connection: FsConnection, from: String,
state.remote.remote_rename(&connection, &from, &to).await
}
/// Remote Host › Download to…: remote files and folders into a local folder, each under its
/// own name.
pub async fn remote_download_into(
window: &str,
state: &Host,
connection: FsConnection,
paths: Vec<String>,
target: IpcPath,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window)?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
let r = state.remote.download_into(&connection, &paths, &target, &token, &mut report).await;
done();
state.end_request(request);
r
}
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
state.remote.test_connection(&connection, &path).await
}
@@ -216,17 +197,6 @@ pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
d.add("remote_download_into", |c| async move {
remote_download_into(
&c.window,
&c.host,
c.arg("connection")?,
c.arg("paths")?,
c.arg("target")?,
c.arg("request")?,
)
.await
});
d.add("remote_rename", |c| async move {
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
});
+1 -21
Переглянути файл
@@ -19,17 +19,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
blocking(move || {
project.documents.open_virtual(
&vd.uri,
vd.language_id,
&vd.text,
&vd.encoding,
vd.has_bom,
vd.can_reload_encoding,
)
})
.await
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
@@ -40,8 +30,6 @@ 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,
@@ -50,14 +38,6 @@ 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
}
+2 -5
Переглянути файл
@@ -176,11 +176,8 @@ async fn binary_and_virtual_documents_are_skipped() {
std::fs::write(&path, b"\0\0binary payload").unwrap();
let binary = f.open(&path).await;
assert!(binary.read_only);
let virtual_doc = f
.project
.documents
.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual", "utf-8", false, false)
.unwrap();
let virtual_doc =
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
std::fs::write(&path, "now ordinary text").unwrap();
f.events.lock().clear();
f.sync().await;
+1 -1
Переглянути файл
@@ -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, None).await;
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
}
host.project(window)?.lsp.virtual_document(&uri).await
}
+2 -10
Переглянути файл
@@ -231,16 +231,8 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// sources of every module in the build, so a repeated run does not rebuild (IDEA's
// up-to-date check) but a change in a dependency module does.
let modules: Vec<std::path::PathBuf> = model
.maven
.iter()
.flat_map(|m| m.projects.iter().map(|p| p.dir.as_str()))
.chain(model.gradle.iter().flat_map(|g| g.projects.iter().map(|p| p.dir.as_str())))
.map(std::path::PathBuf::from)
.collect();
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config, &modules) {
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
+1 -59
Переглянути файл
@@ -20,13 +20,10 @@ struct LogAccess {
}
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
/// `encoding` re-decodes the blob with it ("Reload with Encoding") instead of
/// the project's detection.
pub async fn revision_virtual_document(
host: &Host,
window: &str,
uri: &str,
encoding: Option<&str>,
) -> IdeResult<ide_lsp::types::VirtualDocument> {
#[derive(Deserialize)]
struct Target {
@@ -50,7 +47,7 @@ pub async fn revision_virtual_document(
.collect::<IdeResult<Vec<_>>>()?;
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let path = target.repo.join(&target.path);
let (text, charset, has_bom) = revision_text(host, window, &path, &target.revision, encoding)
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
.await?
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
Ok(ide_lsp::types::VirtualDocument {
@@ -60,63 +57,8 @@ pub async fn revision_virtual_document(
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
decompiled: false,
encoding: charset.name().to_ascii_lowercase(),
has_bom,
can_reload_encoding: true,
})
}
/// Revision text with the charset it was decoded with and whether a matching
/// BOM was stripped; `None` when the file is absent at the revision.
async fn revision_text(
host: &Host,
window: &str,
path: &Path,
rev: &str,
encoding: Option<&str>,
) -> IdeResult<Option<(String, &'static encoding_rs::Encoding, bool)>> {
let project = host.project(window).ok();
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
});
let (path, rev, encoding) = (path.to_path_buf(), rev.to_owned(), encoding.map(str::to_owned));
blocking(move || {
fn with_repo<T>(
project: &Option<std::sync::Arc<Project>>,
external: &Option<std::sync::Arc<ide_vcs::Repo>>,
path: &Path,
f: impl FnOnce(&ide_vcs::Repo) -> IdeResult<T>,
) -> IdeResult<T> {
match project {
Some(project) => project.with_log_repo_of(path, f),
None => f(external
.as_ref()
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?),
}
}
let (text_encoding, charset) = match &encoding {
Some(label) => {
let charset = encoding_rs::Encoding::for_label(label.trim().as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid(format!("unknown encoding {label:?}")))?;
(ide_vcs::TextEncoding::from(charset), charset)
}
None => match &project {
Some(project) => {
let text_encoding = project.vcs_encoding(&path, Some(&rev))?;
(text_encoding, text_encoding.charset)
}
None => (ide_vcs::TextEncoding::from(encoding_rs::UTF_8), encoding_rs::UTF_8),
},
};
let bytes = with_repo(&project, &external, &path, |r| r.file_bytes_at(&path, Some(&rev)))?;
let Some(bytes) = bytes else { return Ok(None) };
let has_bom = encoding_rs::Encoding::for_bom(&bytes).is_some_and(|(enc, _)| enc == charset);
let text =
with_repo(&project, &external, &path, |r| r.file_at(&path, Some(&rev), text_encoding))?.unwrap_or_default();
Ok(Some((text, charset, has_bom)))
})
.await
}
impl LogAccess {
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
let project = host.project(window).ok();
-7
Переглянути файл
@@ -896,13 +896,6 @@ impl Shared {
self.prompts_blocked.lock().remove(plugin);
}
/// A window other than `client` where `plugin` waits for the user's answer (a prompt or a
/// Commit request). The plugin runs one call at a time, so anything else asked of it waits
/// behind that answer.
pub fn waiting_elsewhere(&self, plugin: &str, client: &str) -> Option<String> {
self.prompts.lock().values().find(|p| p.plugin == plugin && p.client != client).map(|p| p.client.clone())
}
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let mut prompts = self.prompts.lock();
-11
Переглянути файл
@@ -1381,17 +1381,6 @@ impl PluginHost {
invocation: Invocation,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<Option<String>> {
// Queued behind a prompt open in another window, the command would sit there with no
// sign of life until that prompt is answered: point the user at it instead.
if let Some(client) = invocation.client.as_deref()
&& let Some(waiting) = self.shared.waiting_elsewhere(id, client)
{
self.sink.emit(Some(&waiting), PluginEvent::PromptWaiting { plugin_id: id.to_owned() });
let name = self.get(id).map(|p| p.manifest().name.clone()).unwrap_or_else(|_| id.to_owned());
return Err(IdeError::invalid(format!(
"{name} is waiting for your answer in another window. Answer or close it there, then try again."
)));
}
let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned));
self.code_call_cancellable(id, invocation, "running the command", cancel, move |rt, inv| {
rt.run_command(&cmd, arg.as_deref(), inv)
+5 -36
Переглянути файл
@@ -121,14 +121,9 @@ pub(crate) struct NavigationIndex {
fn contains(range: Range, pos: Pos) -> bool {
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
}
/// Inside the document, start not after end. An empty range marks a position (a template file's
/// definition at its start, say): navigation lands there, but it has no text to rename.
fn valid_range(lines: &[usize], range: Range) -> bool {
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) <= (range.end.line, range.end.col)
}
fn is_empty(range: Range) -> bool {
(range.start.line, range.start.col) == (range.end.line, range.end.col)
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
}
impl PluginHost {
@@ -285,20 +280,12 @@ impl PluginHost {
.chain(&edges.references)
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid(format!(
"{}: invalid navigation anchors in {}",
plugin.id(),
path.display()
)));
return Err(IdeError::invalid("invalid navigation anchors"));
}
if edges.markers.len() > MAX_EDGES
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid(format!(
"{}: invalid navigation markers in {}",
plugin.id(),
path.display()
)));
return Err(IdeError::invalid("invalid navigation markers"));
}
let mut index = self.navigation.lock();
if index.epochs.get(root).copied() != generation {
@@ -419,7 +406,7 @@ impl PluginHost {
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
for candidate in &file.edges.definitions {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
@@ -427,7 +414,7 @@ impl PluginHost {
}
}
let mut matches = HashMap::new();
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
for candidate in &file.edges.references {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
@@ -593,21 +580,3 @@ impl ExtensionPoint for IndexersPoint {
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ranges_inside_the_document_are_valid_positions() {
let lines = [3, 0];
let range = |a: (u32, u32), b: (u32, u32)| Range::new(Pos::new(a.0, a.1), Pos::new(b.0, b.1));
assert!(valid_range(&lines, range((0, 0), (0, 0))));
assert!(valid_range(&lines, range((1, 0), (1, 0))));
assert!(valid_range(&lines, range((0, 1), (0, 3))));
assert!(!valid_range(&lines, range((0, 2), (0, 1))), "reversed");
assert!(!valid_range(&lines, range((0, 4), (0, 4))), "past the line end");
assert!(!valid_range(&lines, range((2, 0), (2, 0))), "past the last line");
assert!(is_empty(range((0, 0), (0, 0))) && !is_empty(range((0, 0), (0, 1))));
}
}
-1
Переглянути файл
@@ -165,7 +165,6 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
-4
Переглянути файл
@@ -321,10 +321,6 @@ pub enum PluginEvent {
/// The call that asked gave up (cancelled, or its plugin stopped): close the prompt.
#[serde(rename_all = "camelCase")]
PromptClosed { prompt_id: String },
/// A command of this window's prompting plugin was run from another window and refused
/// while the plugin waits for the answer here: bring this window to the front.
#[serde(rename_all = "camelCase")]
PromptWaiting { plugin_id: String },
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
+2 -12
Переглянути файл
@@ -13,7 +13,7 @@ use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -50,8 +50,6 @@ pub struct Project {
pub cache_dir: PathBuf,
/// Superseded launch snapshots retained until this project session ends.
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
/// Reasons this window was already told why Kotlin is debugged with kotlin-debug-adapter.
pub(crate) kotlin_debug_notices: Mutex<HashSet<&'static str>>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -565,7 +563,6 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -599,12 +596,6 @@ impl Host {
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.agents.set_security_hook(Arc::new(move || {
weak.upgrade().map(|p| p.settings.effective().agent_security).unwrap_or_default()
}));
}
self.insert_project(window, project.clone());
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
@@ -727,7 +718,6 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -1231,7 +1221,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
/// The roots a file may navigate into: its own root (the workspace member containing it, or
/// the project root) plus that root's configured dependencies. A workspace member depends
/// only on the roots picked per member in Project Structure › Dependencies —
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
/// being attached to the workspace never makes its code navigable by itself.
/// Cached navigation roots per (project, file), with the instant they were computed.
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
+2 -8
Переглянути файл
@@ -93,15 +93,9 @@ async fn external_history_is_readable_and_never_becomes_a_write_target() {
let payload =
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
let uri = format!("git-revision:{hex}/deleted.txt");
let document = vcs::revision_virtual_document(&host, "standalone", &uri, None).await.unwrap();
let document =
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
assert_eq!(document.text, "historical content\n");
assert_eq!(document.encoding, "utf-8");
assert!(document.can_reload_encoding);
// Reload with Encoding re-decodes the blob with the chosen charset.
let reloaded = vcs::revision_virtual_document(&host, "standalone", &uri, Some("utf-16le")).await.unwrap();
assert_eq!(reloaded.encoding, "utf-16le");
assert_ne!(reloaded.text, document.text);
host.trust.decide(&root, true, false).unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
-29
Переглянути файл
@@ -4157,35 +4157,6 @@ fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget(
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
}
#[test]
fn a_command_from_another_window_is_refused_while_the_plugin_waits_for_an_answer() {
let (f, doc) = interactive_actions_fixture();
let action = f
.host
.provide_code_actions(&doc, ide_core::Range::default())
.into_iter()
.find(|(_, action)| action.data.as_deref() == Some("pick"))
.unwrap()
.1;
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
// Queued behind the open prompt it would not run until the prompt is answered.
let other = Invocation { client: Some("other-window".into()), ..Default::default() };
let err = f.host.run_command("misbehave", "anything", None, other).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
assert!(err.message.contains("waiting for your answer in another window"), "{}", err.message);
let events = f.take_events();
assert!(
events.iter().any(|(client, e)| client.as_deref() == Some("editor-window")
&& matches!(e, PluginEvent::PromptWaiting { plugin_id } if plugin_id == "misbehave")),
"the prompting window is brought forward: {events:?}"
);
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
running.join().unwrap().unwrap();
});
}
#[test]
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
for (how, kind) in [
-1
Переглянути файл
@@ -330,7 +330,6 @@ async fn local_deployment_runs_on_the_project_host() {
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"db_sources",
"db_save_sources",
"db_set_password",
-1
Переглянути файл
@@ -94,7 +94,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"remote_delete",
"remote_mkdir",
"remote_rename",
"remote_download_into",
"remote_import_local",
"remote_bootstrap_exec",
"deploy_upload",
-121
Переглянути файл
@@ -105,89 +105,6 @@ pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeR
Ok(path)
}
/// Where `{{name}}` is defined in the environment files that apply to `file`: the key `name`
/// in each environment (and `$shared`) of the nearest private file, then of the nearest public
/// file — the lookup order. Positions are 0-based lines and UTF-16 columns of the key, quotes
/// included.
pub fn variable_definitions(file: &Path, root: Option<&Path>, name: &str) -> Vec<ide_core::Location> {
let dirs = search_dirs(Some(file), root);
let mut out = Vec::new();
for file_name in [PRIVATE_FILE, PUBLIC_FILE] {
let Some(path) = dirs.iter().map(|d| d.join(file_name)).find(|p| p.is_file()) else { continue };
let Ok(text) = ide_vfs::load_text(&path) else { continue };
for range in environment_keys(&text.text, name) {
out.push(ide_core::Location { path: path.clone().into(), range, uri: None });
}
}
out
}
/// The ranges of keys equal to `name` one level inside the top-level object (an
/// environment's variables), found by scanning the JSON text without building a value.
fn environment_keys(text: &str, name: &str) -> Vec<ide_core::Range> {
use ide_core::{Pos, Range};
let mut found = Vec::new();
let (mut line, mut col) = (0u32, 0u32);
let mut depth = 0usize;
let mut chars = text.char_indices().peekable();
// Whether the last string closed at depth 2 may be a key: set after `{` or `,`.
let mut expect_key = false;
while let Some((_, c)) = chars.next() {
match c {
'"' => {
let start = Pos::new(line, col);
col += 1;
let mut value = String::new();
let mut escaped = false;
for (_, c) in chars.by_ref() {
col += c.len_utf16() as u32;
if escaped {
value.push(match c {
'n' => '\n',
't' => '\t',
other => other,
});
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
break;
} else {
value.push(c);
}
}
let end = Pos::new(line, col);
if depth == 2 && expect_key && value == name {
let mut rest = chars.clone();
while rest.peek().is_some_and(|(_, c)| c.is_whitespace()) {
rest.next();
}
if rest.peek().is_some_and(|(_, c)| *c == ':') {
found.push(Range { start, end });
}
}
expect_key = false;
continue;
}
'{' | '[' => {
depth += 1;
expect_key = c == '{';
}
'}' | ']' => depth = depth.saturating_sub(1),
',' => expect_key = true,
':' => expect_key = false,
'\n' => {
line += 1;
col = 0;
continue;
}
_ => {}
}
col += c.len_utf16() as u32;
}
found
}
impl Environments {
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
let dirs = search_dirs(file, root);
@@ -304,44 +221,6 @@ impl Resolver<'_> {
mod tests {
use super::*;
#[test]
fn finds_variable_keys_in_every_environment_private_first() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("api")).unwrap();
std::fs::write(
root.join(PUBLIC_FILE),
"{\n \"$shared\": { \"host\": \"shared\" },\n \"dev\": {\n \"name\": \"host\",\n \"host\": \"localhost\",\n \"ssl\": { \"host\": \"nested\" }\n },\n \"prod\": { \"hostname\": \"x\", \"host\": \"example.com\" }\n}\n",
)
.unwrap();
std::fs::write(root.join("api").join(PRIVATE_FILE), "{ \"dev\": { \"😀\": 1, \"host\": \"secret\" } }")
.unwrap();
let file = root.join("api").join("requests.http");
let found = variable_definitions(&file, Some(root), "host");
let at: Vec<_> = found
.iter()
.map(|l| {
(
l.path.0.file_name().unwrap().to_string_lossy().into_owned(),
l.range.start.line,
l.range.start.col,
l.range.end.col,
)
})
.collect();
assert_eq!(
at,
vec![
(PRIVATE_FILE.to_owned(), 0, 20, 26),
(PUBLIC_FILE.to_owned(), 1, 15, 21),
(PUBLIC_FILE.to_owned(), 4, 4, 10),
(PUBLIC_FILE.to_owned(), 7, 29, 35),
],
"values and nested objects are not definitions"
);
assert!(variable_definitions(&file, Some(root), "missing").is_empty());
}
#[test]
fn merges_private_public_and_shared() {
let envs = Environments::from_json(
-16
Переглянути файл
@@ -55,10 +55,6 @@ pub struct LanguageConfig {
/// the first one whose root marker is found applies.
#[serde(default)]
pub framework_ui_modes: Vec<FrameworkUiMode>,
/// Files of this language in a framework's projects that their extension alone would
/// give to another language (Django templates are `.html` files in `templates`).
#[serde(default)]
pub framework_files: Vec<FrameworkFiles>,
#[serde(default)]
pub tree_sitter: Option<String>,
#[serde(default)]
@@ -94,18 +90,6 @@ pub struct FrameworkUiMode {
pub root_markers: Vec<String>,
}
/// Files in a framework's folder: a file with one of `extensions` below a folder named
/// `folder`, which has a root marker in a directory above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct FrameworkFiles {
pub folder: String,
/// Without the dot, compared ignoring case.
pub extensions: Vec<String>,
/// Matched like language servers' `root-markers`, e.g. `manage.py`.
pub root_markers: Vec<String>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all(serialize = "camelCase", deserialize = "kebab-case"))]
pub struct IndentConfig {
+7 -54
Переглянути файл
@@ -22,7 +22,6 @@ const BUILTIN: &[(&str, &str)] = &[
("d", include_str!("../../../languages/d.toml")),
("dart", include_str!("../../../languages/dart.toml")),
("diff", include_str!("../../../languages/diff.toml")),
("django-html", include_str!("../../../languages/django-html.toml")),
("dockerfile", include_str!("../../../languages/dockerfile.toml")),
("dotenv", include_str!("../../../languages/dotenv.toml")),
("elixir", include_str!("../../../languages/elixir.toml")),
@@ -372,13 +371,14 @@ impl LanguageRegistry {
.map(|f| f.ui_mode.clone())
}
/// Creating or removing `path` can change a file's `framework_ui_mode` or its
/// language (`framework-files`): it is a framework's root marker.
/// Creating or removing `path` can change a file's `framework_ui_mode`:
/// it is a framework's root marker.
pub fn is_framework_marker(&self, path: &Path) -> bool {
let state = self.state();
let modes = state.languages.iter().flat_map(|l| &l.framework_ui_modes).map(|f| &f.root_markers);
let files = state.languages.iter().flat_map(|l| &l.framework_files).map(|f| &f.root_markers);
modes.chain(files).flatten().any(|m| crate::markers::is_marker_path(path, m))
self.state()
.languages
.iter()
.flat_map(|l| &l.framework_ui_modes)
.any(|f| f.root_markers.iter().any(|m| crate::markers::is_marker_path(path, m)))
}
/// Detects by Override File Type, exact file name, then file-name pattern
@@ -400,9 +400,6 @@ impl LanguageRegistry {
if let Some((_, i)) = t.name_globs.iter().find(|(p, _)| wildcard_match(p, file_name)) {
return languages[*i].id.clone();
}
if let Some(l) = languages.iter().find(|l| l.framework_files.iter().any(|f| is_framework_file(f, path))) {
return l.id.clone();
}
let lower = file_name.to_ascii_lowercase();
let mut rest = lower.as_str();
while let Some(dot) = rest.find('.') {
@@ -423,19 +420,6 @@ impl LanguageRegistry {
}
}
/// `path` is one of `files`: its extension is listed, and the nearest folder above it named
/// `files.folder` has a root marker in a directory above that folder.
fn is_framework_file(files: &crate::FrameworkFiles, path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else { return false };
if !files.extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
return false;
}
path.ancestors()
.skip(1)
.find(|dir| dir.file_name().is_some_and(|name| name == files.folder.as_str()))
.is_some_and(|folder| crate::marked_root(None, folder, &files.root_markers).is_some())
}
/// `*` matches any (possibly empty) run of characters, `?` exactly one; everything else is literal.
fn wildcard_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
@@ -513,7 +497,6 @@ mod tests {
"Brainfuck",
"Cobol",
"C#",
"Django Template",
"Clojure",
"ClojureScript",
"Closure Stylesheets (GSS)",
@@ -818,36 +801,6 @@ mod tests {
assert!(!reg.is_framework_marker(&root.join("apps/admin/package.json")));
}
#[test]
fn django_projects_make_html_in_templates_folders_templates() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
std::fs::create_dir_all(root.join("src/orders/templates/orders/email")).unwrap();
std::fs::create_dir_all(root.join("site/templates")).unwrap();
std::fs::write(root.join("src/manage.py"), "").unwrap();
assert_eq!(detect("src/orders/templates/orders/detail.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/email/body.TXT"), "django-html");
assert_eq!(detect("src/templates/base.html"), "django-html");
assert_eq!(detect("src/orders/templates/orders/data.json"), "json");
assert_eq!(detect("src/orders/static/orders/page.html"), "html");
// No `manage.py` above this `templates` folder.
assert_eq!(detect("site/templates/index.html"), "html");
assert_eq!(detect("page.djhtml"), "django-html");
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
}
#[test]
fn the_django_fixture_has_templates_and_a_page() {
let reg = reg();
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/django-app");
let detect = |file: &str| reg.detect(&fixture.join(file), None).as_str().to_owned();
assert_eq!(detect("shop/templates/shop/product_list.html"), "django-html");
assert_eq!(detect("shop/templates/shop/email/order.txt"), "django-html");
assert_eq!(detect("pages/promo.html"), "html");
}
#[test]
fn markers_outside_the_project_do_not_make_angular_templates() {
let reg = reg();
-1
Переглянути файл
@@ -29,5 +29,4 @@ zip.workspace = true
[dev-dependencies]
tempfile.workspace = true
encoding_rs.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] }
-223
Переглянути файл
@@ -1,223 +0,0 @@
//! 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()
)]
);
}
}
-3
Переглянути файл
@@ -398,9 +398,6 @@ 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, &params)
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
mgr.java_import_ready(self.server.clone(), self.generation);
}
+1 -1
Переглянути файл
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub(crate) const SERVER: &str = "jdtls";
const SERVER: &str = "jdtls";
pub(crate) mod configuration;
mod formatter;
-195
Переглянути файл
@@ -1,195 +0,0 @@
//! 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);
}
}
+1 -1
Переглянути файл
@@ -146,7 +146,7 @@ fn required_env_set(config: &LanguageServerConfig) -> bool {
}
/// A configured `PATH` (e.g. a selected toolchain prepended) also resolves the binary.
pub(crate) fn server_search_path(config: &LanguageServerConfig) -> OsString {
fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
+28 -259
Переглянути файл
@@ -15,13 +15,11 @@ 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;
@@ -40,7 +38,6 @@ 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};
@@ -222,8 +219,6 @@ 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.
@@ -322,48 +317,9 @@ 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.
@@ -378,9 +334,8 @@ 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, 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();
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();
Location { path: source.clone().into(), range, uri: Some(source) }
})
.collect()
@@ -472,7 +427,6 @@ 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(),
@@ -1072,7 +1026,6 @@ 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,
@@ -1103,13 +1056,6 @@ 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()
{
@@ -1251,7 +1197,6 @@ 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) {
@@ -2429,25 +2374,8 @@ impl LspManager {
let query = || async {
let mut params = snap.pos_params(t, pos);
params["newName"] = json!(new_name);
// 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?,
}
};
let r: Option<lsp::WorkspaceEdit> =
self.call(t, "textDocument/rename", params, Some(cancel), LONG_TIMEOUT).await?;
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.
@@ -3069,9 +2997,8 @@ 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, None).map(|(uri, loaded)| {
let range =
name.as_deref().map(|n| library_source::declaration_range(&loaded.text, n)).unwrap_or_default();
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();
Location { path: uri.clone().into(), uri: Some(uri), range }
})
})
@@ -3113,10 +3040,28 @@ impl LspManager {
if scheme == "jar" {
let found = {
let uri = uri.to_owned();
tokio::task::spawn_blocking(move || read_jar_source(&uri, None)).await.ok().flatten()
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()
};
if let Some((entry, loaded)) = found {
return Ok(self.store_jar_entry(uri, entry, loaded));
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),
));
}
}
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
@@ -3187,21 +3132,7 @@ impl LspManager {
continue;
}
};
// 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));
return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id)));
}
if jar_class && error.is_none() {
return Err(IdeError::not_found(format!(
@@ -3213,7 +3144,6 @@ 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,
@@ -3222,7 +3152,6 @@ 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)
@@ -3243,9 +3172,6 @@ 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);
@@ -3258,163 +3184,6 @@ 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>) {
+33 -119
Переглянути файл
@@ -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,32 +57,29 @@ impl JarEntry {
}
}
/// 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 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 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, encoding: Option<&str>) -> Option<(String, LoadedText)> {
pub(crate) fn class_source(uri: &str) -> Option<(String, String)> {
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, encoding) {
if let Some((entry, text)) = source_entry(&sources, class) {
return Some((jar.uri_of(&sources, &entry), text));
}
}
None
}
pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str>) -> Option<(String, LoadedText)> {
pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> {
let jar = if uri.starts_with("jdt://contents/") {
let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?;
let parts: Vec<_> = path.split('/').collect();
@@ -99,7 +96,7 @@ pub(crate) fn source_in_archive(uri: &str, sources: &Path, encoding: Option<&str
parse(uri)?
};
let class = jar.entry.strip_suffix(".class")?;
let (entry, text) = source_entry(sources, class, encoding)?;
let (entry, text) = source_entry(sources, class)?;
Some((jar.uri_of(sources, &entry), text))
}
@@ -120,8 +117,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, encoding: Option<&str>) -> Option<(String, LoadedText)> {
let zip = zip::ZipArchive::new(std::fs::File::open(sources).ok()?).ok()?;
fn source_entry(sources: &Path, class: &str) -> Option<(String, String)> {
let mut 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}/") };
@@ -130,9 +127,13 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
if let Some(file) = outer.strip_suffix("Kt").filter(|f| !f.is_empty()) {
candidates.push(format!("{dir}{file}.kt"));
}
let read = |entry: &str| read_entry(sources, entry, encoding);
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)
};
for entry in &candidates {
if let Some(text) = read(entry) {
if let Some(text) = read(&mut zip, entry) {
return Some((entry.clone(), text));
}
}
@@ -144,8 +145,8 @@ fn source_entry(sources: &Path, class: &str, encoding: Option<&str>) -> Option<(
.collect();
let jvm_name = format!("@file:JvmName(\"{outer}\")");
for entry in kotlin_files {
if let Some(text) = read(&entry)
&& text.text.contains(&jvm_name)
if let Some(text) = read(&mut zip, &entry)
&& text.contains(&jvm_name)
{
return Some((entry, text));
}
@@ -232,15 +233,11 @@ 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, bytes) in entries {
for (name, text) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
@@ -277,12 +274,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, None).unwrap();
let (uri, text) = source_in_archive(uri, &sources).unwrap();
assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}");
assert!(text.text.contains("class Lib"));
assert!(text.contains("class Lib"));
}
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());
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());
}
#[test]
@@ -315,103 +312,20 @@ mod tests {
("org/springframework/boot/Builder.class", "Builder.kt"),
("Root.class", "Root.java"),
] {
let (source_uri, text) =
class_source(&uri(class), None).unwrap_or_else(|| panic!("{class} in {binary:?}"));
let (source_uri, text) = class_source(&uri(class)).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, None)).map(|l| l.text),
Some(text.text.clone())
parse(&source_uri).and_then(|e| read_entry(&e.jar, &e.entry)).as_deref(),
Some(text.as_str())
);
}
assert!(class_source(&uri("org/springframework/boot/Missing.class"), None).is_none());
assert_eq!(class_source(&uri("org/springframework/boot/Missing.class")), None);
}
// No sources jar: nothing to show from here.
let lone = tmp.path().join("lone/x-1.jar");
jar(&lone, &[]);
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"));
assert_eq!(class_source(&format!("jar://{}!/X.class", lone.to_string_lossy().replace('\\', "/"))), None);
}
#[test]
-13
Переглянути файл
@@ -148,8 +148,6 @@ 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 {
@@ -195,7 +193,6 @@ 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,
}
}
@@ -252,20 +249,10 @@ 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`.
-8
Переглянути файл
@@ -357,14 +357,6 @@ 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`).
+10 -64
Переглянути файл
@@ -23,20 +23,9 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'}');
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
} else {
path.to_string_lossy().into_owned()
};
let s = path.to_string_lossy();
let s = if cfg!(windows) { s.replace('\\', "/") } else { s.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('/');
}
@@ -52,30 +41,21 @@ pub fn path_to_uri(path: &Path) -> lsp_types::Uri {
})
}
/// 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.
/// Converts a `file:` URI to a path; `None` for other schemes.
pub fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("FILE://"))?;
let (host, path) = rest.split_at(rest.find('/')?);
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
// 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 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);
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;
return Some(PathBuf::from(trimmed.replace('/', "\\")));
}
Some(PathBuf::from(decoded.into_owned()))
}
@@ -109,40 +89,6 @@ 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";
+1 -631
Переглянути файл
@@ -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::{KotlinDebugServer, KotlinDebugUnavailable, LaunchError, Launcher, LspManager};
use ide_lsp::{LaunchError, Launcher, LspManager};
use ide_text::AppliedChange;
use parking_lot::Mutex;
use serde_json::{Value, json};
@@ -306,7 +306,6 @@ 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,
@@ -3212,33 +3211,6 @@ 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())];
@@ -3669,340 +3641,6 @@ 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 = &params["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();
@@ -6325,208 +5963,6 @@ 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"] {
@@ -6715,69 +6151,3 @@ 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;
}
-1
Переглянути файл
@@ -55,7 +55,6 @@ 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,
+1 -1
Переглянути файл
@@ -69,7 +69,7 @@ pub struct TransferError {
}
impl TransferReport {
pub(crate) fn fail(&mut self, path: impl Into<String>, e: IdeError) {
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
self.errors.push(TransferError { path: path.into(), message: e.message });
}
}
-131
Переглянути файл
@@ -286,70 +286,6 @@ 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)
@@ -561,70 +497,3 @@ 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);
}
}
-34
Переглянути файл
@@ -319,37 +319,3 @@ 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:?}");
}
+8 -179
Переглянути файл
@@ -863,48 +863,6 @@ 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()?;
@@ -944,16 +902,14 @@ 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. 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 {
/// 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 {
let p = &config.extras.params;
if p.get("deployment").map(String::as_str).unwrap_or("artifact") != "artifact" {
return true;
}
let module = artifact_module_dir(root, config);
let module = module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
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()));
@@ -967,24 +923,7 @@ pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig, modules
};
let Ok(war_time) = fs::metadata(&war).and_then(|m| m.modified()) else { return false };
let mut newest = newest_mtime_under(&module.join("src"));
// 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 dir in [module.as_path(), root] {
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)
@@ -1007,7 +946,6 @@ 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) {
@@ -1016,7 +954,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, Some(module.as_path())).ok_or_else(|| {
None => find_built_war(root, config.cwd.as_ref().map(|c| c.0.as_path())).ok_or_else(|| {
invalid("No built WAR artifact found - build the project first, or add 'Build' to Before launch")
})?,
};
@@ -1654,119 +1592,10 @@ 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, &[]));
}
#[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"));
assert!(!artifact_is_current(root, &base, &c));
}
#[test]
+1 -1
Переглянути файл
@@ -1541,7 +1541,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
install_bundle(
data_dir,
"java-usages",
"1.13.0",
"1.12.0",
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
)
}
+1 -51
Переглянути файл
@@ -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())
|| idea_java_project(root)
|| root.join(".idea").is_dir()
|| !s.languages.java.jdk.trim().is_empty()
|| !s.project_structure.source_roots.is_empty();
if !java {
@@ -561,27 +561,6 @@ 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())]);
@@ -1284,32 +1263,3 @@ 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));
}
}
+21 -142
Переглянути файл
@@ -5,7 +5,6 @@ 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)]
@@ -16,43 +15,10 @@ 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
@@ -152,60 +118,28 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
}
}
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());
}
}
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);
}
// `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);
}
FileStatus {
path: r.abs_path(&rel).into(),
rel_path: rel,
staged: e.staged,
unstaged: e.unstaged,
renamed_from: e.renamed_from,
}
"?" if !rest.is_empty() && !ignored.is_ignored_path(rest) => {
map.entry(rest.to_owned()).or_default().unstaged = Some(ChangeKind::Untracked);
}
_ => {}
}
}
(map, conflicts)
})
.collect())
}
/// Lets a test land an external index write in the window between the status run
@@ -293,59 +227,4 @@ 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"]);
}
}
-21
Переглянути файл
@@ -2015,24 +2015,3 @@ 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));
}
+1 -79
Переглянути файл
@@ -359,32 +359,6 @@ 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
@@ -467,7 +441,7 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool, private: b
fs::set_permissions(&tmp, permissions)?;
}
drop(file);
retrying(RENAME_ATTEMPTS, RENAME_PAUSE, held_by_another_process, || fs::rename(&tmp, &target))
fs::rename(&tmp, &target)
})();
if let Err(e) = result {
let _ = fs::remove_file(&tmp);
@@ -565,58 +539,6 @@ 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() {
+6 -69
Переглянути файл
@@ -25,11 +25,6 @@ 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)]
@@ -148,9 +143,6 @@ 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,
@@ -347,7 +339,6 @@ 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(),
@@ -421,8 +412,6 @@ 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,
@@ -460,19 +449,8 @@ 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. `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> {
/// never touches the disk and cannot be edited or saved.
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
let _operation = self.operations.lock();
let key = PathBuf::from(uri);
if let Some(info) = self.reopen(&key) {
@@ -484,8 +462,8 @@ impl Documents {
doc: Document::new(text),
language,
ui_mode: None,
encoding: encoding.into(),
has_bom,
encoding: "utf-8".into(),
has_bom: false,
native_to_ascii: false,
read_only: true,
large,
@@ -493,8 +471,6 @@ impl Documents {
observed: true,
binary: false,
dirty: false,
can_reload_encoding,
encoding_chosen: false,
disk_mtime: 0.0,
disk_len: 0,
closed: false,
@@ -870,7 +846,6 @@ 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);
@@ -889,45 +864,12 @@ 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| {
@@ -1026,13 +968,8 @@ impl Documents {
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
} else {
let defaults = self.file_defaults();
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;
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
loaded
};
st.disk_mtime = loaded.mtime_ms;
+1 -6
Переглянути файл
@@ -748,7 +748,6 @@ 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);
@@ -790,11 +789,7 @@ impl Documents {
return Err(stale(path));
}
let defaults = self.file_defaults();
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))?
};
let loaded = 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;
+2 -106
Переглянути файл
@@ -505,48 +505,6 @@ 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();
@@ -698,14 +656,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", "utf-8", false, true).unwrap();
let info = f.docs.open_virtual(uri, LanguageId::new("java"), "class C {}\n").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", "utf-8", false, true).unwrap();
let again = f.docs.open_virtual(uri, LanguageId::new("java"), "ignored").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();
@@ -719,68 +677,6 @@ 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();
-27
Переглянути файл
@@ -162,21 +162,6 @@ 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();
@@ -530,18 +515,6 @@ 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();
+3 -5
Переглянути файл
@@ -11,10 +11,9 @@ 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. 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.
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.
You can add or remove file name patterns per language in **Settings › Editor › File Types** (**Recognized File Types**).
@@ -107,7 +106,6 @@ 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 | — | — | — |
-6
Переглянути файл
@@ -125,12 +125,6 @@ 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.
+1
Переглянути файл
@@ -52,6 +52,7 @@ 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 |
+3 -5
Переглянути файл
@@ -11,10 +11,9 @@ OpenIDEA 用一个小的定义文件 `languages/<id>.toml` 描述每种语言:
1. 单个文件的 **覆盖文件类型**(在项目树中文件的右键菜单或编辑器标签页的右键菜单里)。
2. 精确文件名(`Dockerfile`、`go.mod`)或文件名模式。
3. 框架目录:上层目录有 `manage.py` 的 `templates` 目录中的 `.html` 或 `.txt` 文件是 Django 模板。
4. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
5. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
6. 都不匹配时按纯文本处理。
3. 扩展名,优先匹配最长的(`.html.erb` 先于 `.erb`)。
4. 文件第一行:例如 `#!/usr/bin/env python3` 这样的 shebang,或 `<?php` 这类模式。
5. 都不匹配时按纯文本处理。
在 **设置 › 编辑器 › 文件类型**(**已识别的文件类型**)中可以为某种语言添加或移除文件名模式。
@@ -102,7 +101,6 @@ 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`, … | 是 | — | — | — |
-6
Переглянути файл
@@ -125,12 +125,6 @@ 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 配置的项目中运行。
+1
Переглянути файл
@@ -52,6 +52,7 @@ 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 服务器 |
+8 -5
Переглянути файл
@@ -85,13 +85,16 @@ 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+;");
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
await expect(dialog).toBeVisible();
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.setViewportSize({ width: 520, height: 760 });
for (const section of ["Modules", "Libraries", "Facets", "Artifacts", "SDKs"]) {
await dialog.getByRole("button", { name: section, exact: true }).click();
const details = dialog.locator("[class*='splitLayout'] > [class*='detailPane']");
await page.getByRole("tab", { name: section, exact: true }).click();
const details = page.locator("[class*='splitLayout'] > [class*='detailPane']");
await expect(details).toBeAttached();
expect((await details.boundingBox())!.width).toBeGreaterThan(200);
}
-30
Переглянути файл
@@ -93,36 +93,6 @@ 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 ({
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"$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": {}
}
+70
Переглянути файл
@@ -0,0 +1,70 @@
{
"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"
]
}
}
+43
Переглянути файл
@@ -0,0 +1,43 @@
{
"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"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"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"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/AbstractList.java#AbstractList"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/ArrayList.java#ArrayList"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/Comparator.java#comparingLong"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/com/example/orders/OrderBook.java:28"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"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"
]
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
{
"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