Compare commits

..
Author SHA1 Message Date
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
195 changed files with 3956 additions and 7099 deletions

No files matched your search

+83
View File
@@ -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
View File
@@ -0,0 +1 @@
../../.agents/skills/eval
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -9,6 +9,7 @@
"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",
+11 -3
View File
@@ -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.
-102
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": [
+15 -176
View File
@@ -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);
}
@@ -1384,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() {
@@ -2237,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 });
@@ -2588,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") })
}
@@ -2647,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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
-166
View File
@@ -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,71 +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());
}
#[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:?}"
);
}
-101
View File
@@ -125,50 +125,6 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
Ok(out)
}
/// 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)>;
@@ -450,61 +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 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
View File
@@ -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
View File
@@ -536,7 +536,6 @@ mod tests {
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"-Djacoco.skip=true",
"test-compile"
]
);
-13
View File
@@ -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"]})
);
}
}
-3
View File
@@ -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(),
}
}
-27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}
+8 -48
View File
@@ -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,16 +610,6 @@ 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 (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
@@ -992,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(
@@ -1000,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,
@@ -1010,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,
}),
@@ -1021,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 {
@@ -1045,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)
@@ -1057,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);
@@ -1394,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")
@@ -1409,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(
@@ -1426,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())
@@ -1546,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.
@@ -1594,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 });
@@ -1619,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?;
}
@@ -1695,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
}
@@ -1703,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
View File
@@ -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}"),
}
}
+2 -286
View File
@@ -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}}));
@@ -2455,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());
@@ -2533,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
),
]
);
}
}
@@ -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());
}
+2 -10
View File
@@ -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)?;
-7
View File
@@ -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
View File
@@ -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
View File
@@ -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))));
}
}
@@ -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
View File
@@ -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 },
+1 -11
View File
@@ -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,
-29
View File
@@ -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 [
-121
View File
@@ -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(
-3
View File
@@ -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);
}
-195
View File
@@ -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);
}
}
+2 -25
View File
@@ -20,7 +20,6 @@ mod handler;
mod injections;
mod inspect;
pub mod jdtls;
mod kotlin_lsp;
mod launch;
mod library_source;
mod loading;
@@ -39,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};
@@ -319,10 +317,6 @@ 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;
@@ -2380,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.
-13
View File
@@ -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`.
+1 -296
View File
@@ -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};
@@ -3211,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())];
@@ -5990,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"] {
@@ -6380,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;
}
+8 -179
View File
@@ -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 -51
View File
@@ -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));
}
}
+11
View File
@@ -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": {}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"documentation": [
"java.util.ArrayList.ArrayList<Order>() Constructs an empty list with an initial capacity of ten. Source: Java 21.0.13.0.2 (module: java.base)"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"documentation": [
"Order com.example.orders.OrderBook.place(String customer, String product, int quantity, long unitPriceCents) Places an order and returns it. Parameters: customer product quantity unitPriceCents Source: openidea-fixture-orders"
]
}
}
@@ -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:32"
]
}
}
@@ -0,0 +1,14 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/ListPrice.java:7",
"src/main/java/com/example/orders/OrderBook.java:11",
"src/main/java/com/example/orders/OrderBook.java:28",
"src/main/java/com/example/orders/OrderBook.java:33"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/App.java:12",
"src/main/java/com/example/orders/OrderBook.java:11",
"src/test/java/com/example/orders/OrderBookTest.java:15"
]
}
}
@@ -0,0 +1,12 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L4:implemented",
"L5:implemented"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L14:overriding"
]
}
}
@@ -0,0 +1,12 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"src/main/java/com/example/orders/BulkDiscount.java:14",
"src/main/java/com/example/orders/ListPrice.java:6"
]
}
}
@@ -0,0 +1,81 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"jdt://contents/java.base/java.lang.invoke/AbstractConstantGroup.java:175",
"jdt://contents/java.base/java.util.concurrent/CopyOnWriteArrayList.java:1269",
"jdt://contents/java.base/java.util.concurrent/CopyOnWriteArrayList.java:1742",
"jdt://contents/java.base/java.util.concurrent/CopyOnWriteArrayList.java:99",
"jdt://contents/java.base/java.util/AbstractList.java:75",
"jdt://contents/java.base/java.util/AbstractList.java:750",
"jdt://contents/java.base/java.util/AbstractList.java:919",
"jdt://contents/java.base/java.util/AbstractSequentialList.java:71",
"jdt://contents/java.base/java.util/ArrayList.java:109",
"jdt://contents/java.base/java.util/ArrayList.java:1194",
"jdt://contents/java.base/java.util/Arrays.java:4229",
"jdt://contents/java.base/java.util/Collections.java:1486",
"jdt://contents/java.base/java.util/Collections.java:1585",
"jdt://contents/java.base/java.util/Collections.java:2691",
"jdt://contents/java.base/java.util/Collections.java:2789",
"jdt://contents/java.base/java.util/Collections.java:3748",
"jdt://contents/java.base/java.util/Collections.java:3836",
"jdt://contents/java.base/java.util/Collections.java:4779",
"jdt://contents/java.base/java.util/Collections.java:5158",
"jdt://contents/java.base/java.util/Collections.java:5368",
"jdt://contents/java.base/java.util/ImmutableCollections.java:254",
"jdt://contents/java.base/java.util/ImmutableCollections.java:434",
"jdt://contents/java.base/java.util/ImmutableCollections.java:553",
"jdt://contents/java.base/java.util/ImmutableCollections.java:660",
"jdt://contents/java.base/java.util/LinkedList.java:1292",
"jdt://contents/java.base/java.util/LinkedList.java:90",
"jdt://contents/java.base/java.util/ReverseOrderListView.java:40",
"jdt://contents/java.base/java.util/ReverseOrderListView.java:55",
"jdt://contents/java.base/java.util/Stack.java:50",
"jdt://contents/java.base/java.util/Vector.java:92",
"jdt://contents/java.base/jdk.internal.classfile.components/ClassPrinter.java:151",
"jdt://contents/java.base/jdk.internal.classfile.impl/ClassPrinterImpl.java:78",
"jdt://contents/java.base/jdk.internal.classfile.impl/Util.java:161",
"jdt://contents/java.base/jdk.internal.org.objectweb.asm.tree/MethodNode.java:281",
"jdt://contents/java.base/sun.net.www.http/KeepAliveStreamCleaner.java:49",
"jdt://contents/java.base/sun.security.jca/ProviderList.java:153",
"jdt://contents/java.base/sun.security.jca/ProviderList.java:431",
"jdt://contents/java.desktop/javax.swing/JEditorPane.java:2010",
"jdt://contents/java.desktop/sun.awt.util/IdentityArrayList.java:95",
"jdt://contents/java.desktop/sun.swing/BakedArrayList.java:47",
"jdt://contents/java.management/com.sun.jmx.remote.internal/ArrayQueue.java:31",
"jdt://contents/java.management/javax.management.relation/RoleList.java:56",
"jdt://contents/java.management/javax.management.relation/RoleUnresolvedList.java:55",
"jdt://contents/java.management/javax.management/AttributeList.java:65",
"jdt://contents/java.xml/com.sun.org.apache.xalan.internal.xsltc.compiler.util/StringStack.java:30",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.util/ByteListImpl.java:36",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/Base64BinaryDV.java:60",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/HexBinaryDV.java:57",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/ListDV.java:58",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/XSSimpleTypeDecl.java:2263",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/XSSimpleTypeDecl.java:2298",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.dv.xs/XSSimpleTypeDecl.java:3447",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/LSInputListImpl.java:38",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/ObjectListImpl.java:35",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/ShortListImpl.java:36",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/StringListImpl.java:38",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs.util/XSObjectListImpl.java:41",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs/PSVIErrorList.java:35",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.impl.xs/XSModelImpl.java:63",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs.datatypes/ByteList.java:34",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs.datatypes/ObjectList.java:33",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/LSInputList.java:33",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/ShortList.java:31",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/StringList.java:31",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/XSNamespaceItemList.java:32",
"jdt://contents/java.xml/com.sun.org.apache.xerces.internal.xs/XSObjectList.java:32",
"jdt://contents/jdk.compiler/com.sun.tools.javac.model/FilteredMemberList.java:46",
"jdt://contents/jdk.compiler/com.sun.tools.javac.util/List.java:53",
"jdt://contents/jdk.compiler/com.sun.tools.javac.util/List.java:79",
"jdt://contents/jdk.jdi/com.sun.tools.jdi/EventSetImpl.java:86",
"jdt://contents/jdk.jshell/jdk.jshell/DiagList.java:38"
]
}
}
@@ -0,0 +1,12 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"src/main/java/com/example/orders/BulkDiscount.java:4",
"src/main/java/com/example/orders/ListPrice.java:4"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L33:1804 usages",
"L73:226 usages",
"L92:3 usages"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L10:3 usages",
"L3:12 usages",
"L6:2 usages"
]
}
}
@@ -0,0 +1,14 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"changes": [
"src/main/java/com/example/orders/ListPrice.java:7",
"src/main/java/com/example/orders/Order.java:11",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"changes": [
"src/main/java/com/example/orders/OrderBook.java:14",
"src/main/java/com/example/orders/OrderBook.java:15",
"src/main/java/com/example/orders/OrderBook.java:16"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/OrderBook.java:20",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29"
]
}
}
@@ -0,0 +1,16 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"jdt://contents/java.base/java.util/Comparator.java:302",
"jdt://contents/java.base/java.util/Comparator.java:307",
"jdt://contents/jdk.jfr/jdk.jfr.internal.dcmd/AbstractDCmd.java:174",
"jdt://contents/jdk.jfr/jdk.jfr.internal.periodic/BatchManager.java:84",
"jdt://contents/jdk.jfr/jdk.jfr.internal/TypeLibrary.java:109",
"src/main/java/com/example/orders/OrderBook.java:24"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/ListPrice.java:7",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29"
]
}
}
@@ -0,0 +1,21 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/com/example/orders/BulkDiscount.java:14",
"src/main/java/com/example/orders/ListPrice.java:6",
"src/main/java/com/example/orders/OrderBook.java:10",
"src/main/java/com/example/orders/OrderBook.java:13",
"src/main/java/com/example/orders/OrderBook.java:14",
"src/main/java/com/example/orders/OrderBook.java:19",
"src/main/java/com/example/orders/OrderBook.java:23",
"src/main/java/com/example/orders/OrderBook.java:24",
"src/main/java/com/example/orders/OrderBook.java:29",
"src/main/java/com/example/orders/PricingRule.java:5",
"src/test/java/com/example/orders/OrderBookTest.java:21"
]
}
}
@@ -0,0 +1,18 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"Pageable",
"PageableArgumentResolver",
"PageableDefault",
"PageableExecutionUtils",
"PageableHandlerMethodArgumentResolver",
"PageableHandlerMethodArgumentResolverCustomizer",
"PageableHandlerMethodArgumentResolverSupport",
"PageableUtils"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/spring-data-commons-4.1.0.jar/org.springframework.data.repository/CrudRepository.java#save"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/spring-data-commons-4.1.0.jar/org.springframework.data.domain/PageRequest.java#PageRequest"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:45"
]
}
}
@@ -0,0 +1,9 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": []
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"documentation": [
"org.springframework.data.domain.PageRequest Basic Java Bean implementation of Pageable. Author: Oliver Gierke Thomas Darimont Anastasiia Smirnova Mark Paluch Thach Le Source: spring-data-commons-4.1.0.jar"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L33:overridden"
]
}
}
@@ -0,0 +1,11 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L29:overridden"
]
}
}
@@ -0,0 +1,20 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"implementations": [
"src/main/java/org/springframework/samples/petclinic/model/NamedEntity.java:31",
"src/main/java/org/springframework/samples/petclinic/model/Person.java:29",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:50",
"src/main/java/org/springframework/samples/petclinic/owner/Pet.java:46",
"src/main/java/org/springframework/samples/petclinic/owner/PetType.java:28",
"src/main/java/org/springframework/samples/petclinic/owner/Visit.java:37",
"src/main/java/org/springframework/samples/petclinic/vet/Specialty.java:30",
"src/main/java/org/springframework/samples/petclinic/vet/Vet.java:45",
"src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java:84",
"src/test/java/org/springframework/samples/petclinic/owner/PetTypeFormatterTests.java:89"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L38:82 usages",
"L76:3 usages",
"L84:3 usages"
]
}
}
@@ -0,0 +1,13 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"changes": [
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:176",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:108",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:229"
]
}
}
@@ -0,0 +1,83 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:107",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:132",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:133",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:141",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:154",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:181",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:182",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:65",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:66",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:78",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:95",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:36",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:39",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:42",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:45",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:48",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:50",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:51",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:55",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java:60",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:101",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:108",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:145",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:186",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:68",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:69",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:70",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:83",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:84",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:66",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:67",
"src/main/java/org/springframework/samples/petclinic/owner/VisitController.java:98",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:114",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:22",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:43",
"src/test/java/org/springframework/samples/petclinic/PetClinicConcurrencyTests.java:45",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:176",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:192",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:201",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:215",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:227",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:302",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:73",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:74",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerControllerTests.java:95",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerTests.java:27",
"src/test/java/org/springframework/samples/petclinic/owner/OwnerTests.java:40",
"src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java:167",
"src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java:258",
"src/test/java/org/springframework/samples/petclinic/owner/PetControllerTests.java:77",
"src/test/java/org/springframework/samples/petclinic/owner/VisitControllerTests.java:61",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:100",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:102",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:112",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:115",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:131",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:133",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:160",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:162",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:188",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:190",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:220",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:222",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:239",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:241",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:256",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:258",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:285",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:287",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:296",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:298",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:34",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:91"
]
}
}
@@ -0,0 +1,24 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:108",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:132",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:161",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:125",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:161",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:86",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:153",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:187",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:122",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:182",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:229",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:234",
"src/test/java/org/springframework/samples/petclinic/service/EntityUtils.java:46",
"src/test/java/org/springframework/samples/petclinic/vet/VetTests.java:38"
]
}
}
+838
View File
@@ -0,0 +1,838 @@
/**
* Java code intelligence with jdtls: the steps users take most, timed as they see them and
* checked against `golden/<id>.json`. On `fixtures/java-app` (small, every case visible) and
* on Spring PetClinic (`evals/projects.json`: a real Spring Boot / Maven project).
*/
import { join } from "node:path";
import { track } from "../lib/measure.ts";
import { normalizePath, type EvalScenario, type Measured } from "../lib/scenario.ts";
import type { Session } from "../lib/session.ts";
const SRC = "src/main/java/com/example/orders";
const TEST = "src/test/java/com/example/orders";
const PET = "src/main/java/org/springframework/samples/petclinic";
const TIMEOUT = 30_000;
/** Results a whole file's worth of server requests make up. */
const LONG_TIMEOUT = 60_000;
interface ProbeWindow {
__openideaPerf: {
editorState(): { path: string; head: number; line: number; word: string } | null;
editorText(): string | null;
setCursor(offset: number): void;
insertAfter(after: string, text: string): number;
completions(): string[];
completionPending(): boolean;
diagnostics(): Array<{ line: number; severity: string; message: string }>;
lineAtY(y: number): number;
};
}
/** The visible editor hint, unless it only says a request is pending. */
async function finalHint(s: Session): Promise<string | null> {
return s.page.evaluate(() => {
const el = document.querySelector(".cm-lsp-hint:not([data-pending])") as HTMLElement | null;
return el && el.getClientRects().length ? el.textContent : null;
});
}
/** Where the caret is: `path:line` in the project, `uri#word` in a library class (its line depends on the JDK build). */
async function caretLocation(s: Session): Promise<string | null> {
const st = await s.page.evaluate(() => (window as unknown as ProbeWindow).__openideaPerf.editorState());
if (!st) return null;
const path = normalizePath(s.root, st.path);
return path.includes("://") ? `${path}#${st.word}` : `${path}:${st.line + 1}`;
}
/**
* Where a step starts: inside `word` on the first line containing `line` of `file`, or of the
* library class reached from `file` by going to the declaration of each `via` in turn. `edit`
* changes `file` first; `typed` changes the file reached right before the step, with no time
* for the server to catch up.
*/
interface Place {
file: string;
edit?: Insertion;
via?: Array<[line: string, word: string]>;
typed?: Insertion;
line: string;
word: string;
}
interface Insertion {
after: string;
text: string;
}
async function reach(s: Session, at: Place) {
await s.openFile(at.file);
if (at.edit) await s.edit(at.edit.after, at.edit.text);
for (const [line, word] of at.via ?? []) await s.declarationOf(line, word);
if (!at.typed) await s.caretAt(at.line, at.word);
}
/** What a step does first when its place is `typed`. */
async function type(s: Session, at: Place) {
if (!at.typed) return;
await s.edit(at.typed.after, at.typed.text);
await s.caretAt(at.line, at.word);
}
/** Text that marks an answer from syntax instead of the language server. */
const SYNTAX = /based on syntax|language server not ready/i;
function declaration(
project: string,
id: string,
title: string,
at: Place,
conditions: EvalScenario["conditions"] = ["warm"],
): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions,
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
await type(s, at);
// Moved to the declaration, or a hint saying why not (not one saying it is still looking).
const answered = (_: null, before: { path: string; head: number } | null) => {
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(".cm-lsp-hint:not([data-pending])");
};
const timing = await track(s.page, {
action: "navigate.declaration",
arg: null,
first: answered,
done: answered,
timeoutMs: TIMEOUT,
});
// A note that the answer came from syntax follows the navigation.
await s.page.waitForTimeout(300);
const hint = await finalHint(s);
const landed = await caretLocation(s);
return {
timing,
results: { declaration: landed ? [landed] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
/** Every usage row of the popup, scrolling its virtual list through. */
async function usageRows(s: Session): Promise<string[]> {
const rows = await s.page.evaluate(async () => {
const popup = document.querySelector('[data-testid="usages-popup"]');
if (!popup) return [];
const found = new Set<string>();
const collect = () => {
for (const r of popup.querySelectorAll<HTMLElement>('[data-testid="usage-row"]'))
found.add(`${r.dataset.path}\u0000${Number(r.dataset.line) + 1}`);
};
const scroller = [...popup.querySelectorAll<HTMLElement>("*")].find((el) => el.scrollHeight > el.clientHeight + 1);
collect();
if (scroller) {
for (let top = 0; top < scroller.scrollHeight; top += scroller.clientHeight / 2) {
scroller.scrollTop = top;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
collect();
}
}
return [...found];
});
return rows.map((r) => {
const [path, line] = r.split("\u0000") as [string, string];
return `${normalizePath(s.root, path)}:${line}`;
});
}
/**
* Show Usages at `at`. `widen`: in Project and Libraries, as invoking it again while the popup
* or its hint offers to widen a Project Files search does (the step is that second invocation).
*/
function showUsages(project: string, id: string, title: string, at: Place, widen = false): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
if (!widen) return;
await s.runAction("find.showUsages");
await s.page.waitForFunction(
() =>
document.querySelector('[data-testid="usages-popup"][data-loading="false"][data-scope="project"]') ||
document.querySelector(".cm-lsp-hint[data-usages]"),
undefined,
{ timeout: TIMEOUT },
);
},
async step(s): Promise<Measured> {
await type(s, at);
const timing = await track(s.page, {
action: "find.showUsages",
arg: widen,
// A single usage is jumped to; none says so in a hint.
first: (widen, before) => {
const popup = document.querySelector<HTMLElement>('[data-testid="usages-popup"]');
if (popup)
return (
(!widen || popup.dataset.scope === "libraries") && !!popup.querySelector('[data-testid="usage-row"]')
);
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(widen ? '.cm-lsp-hint[data-scope="libraries"]' : ".cm-lsp-hint[data-usages]");
},
done: (widen, before) => {
const popup = document.querySelector<HTMLElement>('[data-testid="usages-popup"]');
if (popup) return (!widen || popup.dataset.scope === "libraries") && popup.dataset.loading === "false";
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(widen ? '.cm-lsp-hint[data-scope="libraries"]' : ".cm-lsp-hint[data-usages]");
},
timeoutMs: TIMEOUT,
});
const popup = await s.page.$('[data-testid="usages-popup"]');
const rows = popup ? await usageRows(s) : [];
// Without a popup the hint gives the count: the only usage was jumped to (or the caret is on it).
const count = await s.page.evaluate(
() => (document.querySelector(".cm-lsp-hint[data-usages]") as HTMLElement | null)?.dataset.usages ?? null,
);
const single = !popup && count === "1" ? await caretLocation(s) : null;
const note = await s.page.evaluate(
() => document.querySelector('[data-testid="usages-note"]')?.textContent ?? null,
);
const hint = await finalHint(s);
return {
timing,
results: { usages: single ? [single] : rows },
degraded: note ?? (hint && SYNTAX.test(hint) ? hint : null),
};
},
};
}
/** Every row of the targets chooser (`path:line`), scrolling its virtual list through. */
async function chooserRows(s: Session): Promise<string[]> {
const rows = await s.page.evaluate(async () => {
const popup = document.querySelector('[data-testid="list-popup"]');
if (!popup) return [];
const found = new Set<string>();
const collect = () => {
for (const r of popup.querySelectorAll<HTMLElement>('[data-testid="list-popup-item"]'))
if (r.dataset.path) found.add(`${r.dataset.path}\u0000${Number(r.dataset.line) + 1}`);
};
const scroller = [...popup.querySelectorAll<HTMLElement>("*")].find((el) => el.scrollHeight > el.clientHeight + 1);
collect();
if (scroller) {
for (let top = 0; top < scroller.scrollHeight; top += scroller.clientHeight / 2) {
scroller.scrollTop = top;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
collect();
}
}
return [...found];
});
return rows.map((r) => {
const [path, line] = r.split("\u0000") as [string, string];
return `${normalizePath(s.root, path)}:${line}`;
});
}
/** Go to Implementation: the chooser's rows once it stops searching, or the only target. */
function implementation(project: string, id: string, title: string, at: Place): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
const before = await caretLocation(s);
// The chooser (busy while the server looks on), the only target, or a hint.
const answered = (searching: boolean, before: { path: string; head: number } | null) => {
const popup = document.querySelector<HTMLElement>('[data-testid="list-popup"]');
if (popup) return searching || popup.getAttribute("aria-busy") !== "true";
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(".cm-lsp-hint:not([data-pending])");
};
// Checks run in the page from their source, so the shared one goes in as the argument's source.
const timing = await track(s.page, {
action: "navigate.implementation",
arg: answered.toString(),
first: (src, before) => (new Function(`return ${src}`)() as typeof answered)(true, before),
done: (src, before) => (new Function(`return ${src}`)() as typeof answered)(false, before),
timeoutMs: TIMEOUT,
});
const popup = await s.page.$('[data-testid="list-popup"]');
const rows = popup ? await chooserRows(s) : [];
const at = await caretLocation(s);
const hint = await finalHint(s);
return {
timing,
results: { implementations: popup ? rows : at && at !== before ? [at] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
/**
* What opening `file`, or the library class reached from it through `via` (see `Place`), brings
* up beside its text: every `selector` with its line, as `L<line>:<its kind or text>`.
*/
function onOpen(
project: string,
id: string,
title: string,
file: string,
via: NonNullable<Place["via"]>,
group: string,
selector: string,
): EvalScenario {
const last = via[via.length - 1];
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
if (!last) return;
await s.openFile(file);
for (const [line, word] of via.slice(0, -1)) await s.declarationOf(line, word);
await s.caretAt(last[0], last[1]);
},
async step(s): Promise<Measured> {
const items = (selector: string) => {
const probe = (window as unknown as ProbeWindow).__openideaPerf;
const out: string[] = [];
for (const m of document.querySelectorAll<HTMLElement>(selector)) {
// A lens is a row of its own above the line it is about.
const r = (m.closest(".cm-openidea-lens")?.nextElementSibling ?? m).getBoundingClientRect();
out.push(`L${probe.lineAtY(r.top + r.height / 2) + 1}:${m.dataset.kind ?? m.textContent}`);
}
return out.sort();
};
const timing = await track(s.page, {
// A library class has no path to open: it is reached the way users do, which also shows its declaration.
...(last ? { action: "navigate.declaration" } : { openFile: join(s.root, file) }),
arg: selector,
first: (selector, before) =>
(window as unknown as ProbeWindow).__openideaPerf.editorState()?.path !== before?.path &&
!!document.querySelector(selector),
done: () => true,
signature: items,
settleMs: 2000,
timeoutMs: LONG_TIMEOUT,
});
return { timing, results: { [group]: await s.page.evaluate(items, selector) } };
},
};
}
const GUTTER = '.cm-editor [class*="cm-openidea-lineMarker-"]';
function gutterOnOpen(project: string, id: string, title: string, file: string, via: Place["via"] = []): EvalScenario {
return onOpen(project, id, title, file, via, "markers", GUTTER);
}
function lensOnOpen(project: string, id: string, title: string, file: string, via: Place["via"] = []): EvalScenario {
return onOpen(project, id, title, file, via, "lenses", ".cm-editor .cm-openidea-lensAction");
}
/** Errors and warnings in the active editor: hints and notes come and go with the server's settings. */
const problems = () =>
(window as unknown as ProbeWindow).__openideaPerf
.diagnostics()
.filter((d) => d.severity === "error" || d.severity === "warning")
.map((d) => `L${d.line + 1}:${d.severity}:${d.message}`)
.sort();
/** Waits for what the previous run left in the active editor to go. */
async function noErrors(s: Session) {
await s.page.waitForFunction(() => !document.querySelector(".cm-lintRange-error"), undefined, { timeout: TIMEOUT });
}
/** Opening a correct `file`: nothing is reported in it, once the server has had its say. */
function diagnosticsOnOpen(project: string, id: string, title: string, file: string): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup() {},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
openFile: join(s.root, file),
arg: null,
first: () => !!document.querySelector(".cm-content .cm-line"),
done: () => true,
signature: problems,
settleMs: 3000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Typing `edit` into a correct file: the error shows under it. */
function diagnosticsAfterEdit(project: string, id: string, title: string, at: Place, edit: Insertion): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await noErrors(s);
await s.keepText();
},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
run: (edit) => (window as unknown as ProbeWindow).__openideaPerf.insertAfter(edit.after, edit.text),
arg: edit,
// The typed text answers the keystroke; the error is the result.
feedback: () => true,
first: () => !!document.querySelector(".cm-lintRange-error"),
done: () => true,
signature: problems,
settleMs: 2000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Typing `fix` elsewhere repairs the mistake `edit` made: only the server can clear its error. */
function diagnosticsAfterFix(
project: string,
id: string,
title: string,
at: Place,
edit: Insertion,
fix: Insertion,
): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await noErrors(s);
await s.edit(edit.after, edit.text);
await s.page.waitForSelector(".cm-lintRange-error", { timeout: TIMEOUT });
},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
run: (fix) => (window as unknown as ProbeWindow).__openideaPerf.insertAfter(fix.after, fix.text),
arg: fix,
feedback: () => true,
first: () => !document.querySelector(".cm-lintRange-error"),
done: () => true,
signature: problems,
settleMs: 2000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Code Completion right after typing `edit`: the lookup's labels. */
function completion(project: string, id: string, title: string, file: string, edit: Insertion): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => s.openFile(file),
async step(s): Promise<Measured> {
const end = await s.edit(edit.after, edit.text);
await s.page.evaluate((o) => (window as unknown as ProbeWindow).__openideaPerf.setCursor(o), end);
const timing = await track(s.page, {
action: "code.completion",
arg: null,
first: () => (window as unknown as ProbeWindow).__openideaPerf.completions().length > 0,
// Quicker sources show first: every source has answered once it stops being pending.
done: () => !(window as unknown as ProbeWindow).__openideaPerf.completionPending(),
timeoutMs: TIMEOUT,
});
const labels = await s.page.evaluate(() => (window as unknown as ProbeWindow).__openideaPerf.completions());
return { timing, results: { completions: labels } };
},
};
}
/** Quick Documentation: the start of what the popup says. */
function quickDoc(project: string, id: string, title: string, at: Place): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
const shown = () => !!document.querySelector('[data-testid="doc-popup"], .cm-lsp-hint:not([data-pending])');
const text = () =>
document.querySelector('[data-testid="doc-popup"]')?.textContent?.replace(/\s+/g, " ").trim().slice(0, 300) ??
"";
const timing = await track(s.page, {
action: "code.quickDocumentation",
arg: null,
feedback: () => !!document.querySelector('[data-testid="doc-popup"]'),
first: shown,
done: shown,
signature: text,
settleMs: 500,
timeoutMs: TIMEOUT,
});
const doc = await s.page.evaluate(text);
const hint = await finalHint(s);
return {
timing,
results: { documentation: doc ? [doc] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
interface RenameAnswer {
edit: { files: Array<{ path?: string; edits?: Array<{ range: { start: { line: number } }; new_text: string }> }> };
}
/** Rename in place to `newName`, then ↵: every edit it makes (a preview is left unapplied). */
function rename(project: string, id: string, title: string, at: Place, newName: string): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await s.keepText();
await s.runAction("refactor.rename");
await s.page.waitForSelector(".cm-lsp-inplace-primary", { timeout: TIMEOUT });
await s.page.keyboard.type(newName);
},
async step(s): Promise<Measured> {
const answered = (newName: string) => {
if (document.querySelector('[data-testid="refactor-preview"]')) return true;
if (document.querySelector(".cm-lsp-hint:not([data-pending]):not(.cm-lsp-inplace-hint)")) return true;
return (
!document.querySelector(".cm-lsp-inplace") &&
!!(window as unknown as ProbeWindow).__openideaPerf.editorText()?.includes(newName)
);
};
const timing = await track(s.page, {
run: () =>
document.querySelector(".cm-content")?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
bubbles: true,
cancelable: true,
}),
),
arg: newName,
first: answered,
done: answered,
timeoutMs: TIMEOUT,
});
// The preview only counts per file: where the name lands is in the answer it shows. A server
// may merge nearby occurrences into one edit, so every line of its new text counts.
const answer = s.lastAnswer.get("lsp_rename") as RenameAnswer | undefined;
const changes = (answer?.edit.files ?? []).flatMap((f) =>
(f.edits ?? []).flatMap((e) =>
e.new_text
.split("\n")
.flatMap((l, i) =>
l.includes(newName) ? [`${normalizePath(s.root, f.path!)}:${e.range.start.line + i + 1}`] : [],
),
),
);
return { timing, results: { changes } };
},
};
}
const APP = "java-app";
const PETCLINIC = "spring-petclinic";
/** From `OrderBook` into the JDK's `ArrayList` / `Collection` sources. */
const BOOK = `${SRC}/OrderBook.java`;
const TO_ARRAYLIST: [string, string] = ["private final List<Order> orders = new ArrayList<>();", "ArrayList"];
/** A new method above the rest of `OrderBook`, calling `revenueCents`. */
const TWICE = "\n public long twice() {\n return revenueCents();\n }\n";
const TYPE_ERROR: Insertion = { after: "public int size() {", text: '\n int broken = "text";' };
const TO_STREAM: [string, string] = ["return orders.stream().mapToLong(Order::totalCents).sum();", "stream"];
export const JAVA_SCENARIOS: EvalScenario[] = [
declaration(
APP,
"java.app.declaration.project",
"Go to declaration: project method",
{ file: `${SRC}/App.java`, line: "long revenue = book.revenueCents();", word: "revenueCents" },
["warm", "cold"],
),
declaration(APP, "java.app.declaration.jdk", "Go to declaration: JDK class", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: TO_ARRAYLIST[1],
}),
declaration(APP, "java.app.declaration.dependency", "Go to declaration: dependency method (JUnit)", {
file: `${TEST}/OrderBookTest.java`,
line: "assertEquals(2500, book.revenueCents());",
word: "assertEquals",
}),
showUsages(APP, "java.app.usages.record", "Show Usages: record used across files", {
file: `${SRC}/Order.java`,
line: "public record Order(",
word: "Order",
}),
showUsages(APP, "java.app.usages.method", "Show Usages: method used through calls and method references", {
file: `${SRC}/Order.java`,
line: "public long totalCents() {",
word: "totalCents",
}),
gutterOnOpen(APP, "java.app.gutter.interface", "Gutter markers: open an interface", `${SRC}/PricingRule.java`),
gutterOnOpen(
APP,
"java.app.gutter.subclass",
"Gutter markers: open an overriding subclass",
`${SRC}/BulkDiscount.java`,
),
// The JDK's sources: their lines depend on its build (`java` in .github/tool-versions.env).
declaration(APP, "java.app.declaration.jdkMethod", "Go to declaration: JDK static method", {
file: BOOK,
line: "return orders.stream().max(Comparator.comparingLong(Order::totalCents));",
word: "comparingLong",
}),
declaration(APP, "java.app.declaration.inJdkSource", "Go to declaration from a JDK class: its superclass", {
file: BOOK,
via: [TO_ARRAYLIST],
line: "extends AbstractList<E>",
word: "AbstractList",
}),
showUsages(
APP,
"java.app.usages.jdkMethodLibraries",
"Show Usages in Project and Libraries: JDK method called once in the project",
{
file: BOOK,
line: "return orders.stream().max(Comparator.comparingLong(Order::totalCents));",
word: "comparingLong",
},
true,
),
showUsages(APP, "java.app.usages.inJdkSource", "Show Usages from a JDK class: a default method the project calls", {
file: BOOK,
via: [TO_STREAM],
line: "default Stream<E> stream() {",
word: "stream",
}),
implementation(APP, "java.app.implementation.jdkInterface", "Go to Implementation: JDK interface", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: "List",
}),
gutterOnOpen(APP, "java.app.gutter.jdkClass", "Gutter markers: open a JDK class", BOOK, [TO_ARRAYLIST]),
lensOnOpen(APP, "java.app.lens.project", "Usage lenses: open a project class", `${SRC}/Order.java`),
lensOnOpen(APP, "java.app.lens.jdkClass", "Usage lenses: open a JDK class", BOOK, [
["public Optional<Order> largest() {", "Optional"],
]),
implementation(APP, "java.app.implementation.projectInterface", "Go to Implementation: project interface", {
file: `${SRC}/PricingRule.java`,
line: "public interface PricingRule {",
word: "PricingRule",
}),
implementation(APP, "java.app.implementation.interfaceMethod", "Go to Implementation: interface method", {
file: `${SRC}/PricingRule.java`,
line: "long price(Order order);",
word: "price",
}),
// Right after an edit: results follow the new text, unsaved and with its lines moved.
declaration(APP, "java.app.edit.declaration", "Go to declaration right after typing above the target", {
file: BOOK,
typed: { after: "public class OrderBook {", text: TWICE },
line: "return revenueCents();",
word: "revenueCents",
}),
showUsages(APP, "java.app.edit.usagesSameFile", "Show Usages right after typing a call in the same file", {
file: BOOK,
typed: { after: "public class OrderBook {", text: TWICE },
line: "public long revenueCents() {",
word: "revenueCents",
}),
showUsages(APP, "java.app.edit.usagesOtherFile", "Show Usages after typing a call in another file", {
file: BOOK,
edit: {
after: "public class OrderBook {",
text: "\n public long firstTotal() {\n return orders.get(0).totalCents();\n }\n",
},
via: [["return orders.get(0).totalCents();", "totalCents"]],
line: "public long totalCents() {",
word: "totalCents",
}),
diagnosticsAfterEdit(
APP,
"java.app.diagnostics.afterError",
"Diagnostics: type a type error",
{ file: BOOK, line: "public class OrderBook {", word: "OrderBook" },
TYPE_ERROR,
),
diagnosticsAfterFix(
APP,
"java.app.diagnostics.afterFix",
"Diagnostics: declare the missing variable",
{ file: BOOK, line: "public class OrderBook {", word: "OrderBook" },
{ after: "public int size() {", text: "\n int used = missing;" },
{ after: "public class OrderBook {", text: "\n private final int missing = 0;" },
),
completion(APP, "java.app.completion.jdkMember", "Code Completion: members of a JDK type", BOOK, {
after: "public int size() {",
text: "\n orders.",
}),
completion(
APP,
"java.app.completion.projectMember",
"Code Completion: members of a project class",
`${SRC}/App.java`,
{
after: "long revenue = book.revenueCents();",
text: "\n book.",
},
),
quickDoc(APP, "java.app.doc.project", "Quick Documentation: project method", {
file: BOOK,
line: "public Order place(String customer, String product, int quantity, long unitPriceCents) {",
word: "place",
}),
quickDoc(APP, "java.app.doc.jdk", "Quick Documentation: JDK class", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: TO_ARRAYLIST[1],
}),
rename(
APP,
"java.app.rename.acrossFiles",
"Rename: method used across files",
{ file: `${SRC}/Order.java`, line: "public long totalCents() {", word: "totalCents" },
"orderTotalCents",
),
rename(
APP,
"java.app.rename.local",
"Rename: local variable",
{ file: BOOK, line: "Order order = new Order(customer, product, quantity, unitPriceCents);", word: "order" },
"placedOrder",
),
declaration(
PETCLINIC,
"java.petclinic.declaration.project",
"Go to declaration: Spring Data query method in the project",
{
file: `${PET}/owner/OwnerController.java`,
line: "return owners.findByLastNameStartingWith(lastname, pageable);",
word: "findByLastNameStartingWith",
},
["warm", "cold"],
),
declaration(
PETCLINIC,
"java.petclinic.declaration.inheritedLibrary",
"Go to declaration: method inherited from a Spring Data interface",
{ file: `${PET}/owner/OwnerController.java`, line: "this.owners.save(owner);", word: "save" },
),
declaration(PETCLINIC, "java.petclinic.declaration.libraryClass", "Go to declaration: Spring Data class", {
file: `${PET}/owner/OwnerController.java`,
line: "Pageable pageable = PageRequest.of(validatedPage - 1, pageSize);",
word: "PageRequest",
}),
showUsages(
PETCLINIC,
"java.petclinic.usages.entity",
"Show Usages: entity class used across controllers, repositories and tests",
{ file: `${PET}/owner/Owner.java`, line: "public class Owner extends Person {", word: "Owner" },
),
showUsages(
PETCLINIC,
"java.petclinic.usages.inheritedMethod",
"Show Usages: base-class getter called through subclasses",
{
file: `${PET}/model/BaseEntity.java`,
line: "public Integer getId() {",
word: "getId",
},
),
gutterOnOpen(
PETCLINIC,
"java.petclinic.gutter.baseClass",
"Gutter markers: open a base entity",
`${PET}/model/BaseEntity.java`,
),
gutterOnOpen(
PETCLINIC,
"java.petclinic.gutter.midClass",
"Gutter markers: open a mid-hierarchy entity",
`${PET}/model/Person.java`,
),
lensOnOpen(PETCLINIC, "java.petclinic.lens.entity", "Usage lenses: open an entity", `${PET}/owner/Owner.java`),
implementation(PETCLINIC, "java.petclinic.implementation.baseClass", "Go to Implementation: base entity", {
file: `${PET}/model/BaseEntity.java`,
line: "public class BaseEntity implements Serializable {",
word: "BaseEntity",
}),
diagnosticsOnOpen(
PETCLINIC,
"java.petclinic.diagnostics.cleanFile",
"Diagnostics: open a controller that compiles",
`${PET}/owner/OwnerController.java`,
),
completion(
PETCLINIC,
"java.petclinic.completion.libraryClass",
"Code Completion: class from a dependency, not imported",
`${PET}/owner/PetValidator.java`,
{ after: "public class PetValidator implements Validator {", text: "\n private Pageab" },
),
quickDoc(PETCLINIC, "java.petclinic.doc.library", "Quick Documentation: Spring Data class", {
file: `${PET}/owner/OwnerController.java`,
line: "Pageable pageable = PageRequest.of(validatedPage - 1, pageSize);",
word: "PageRequest",
}),
rename(
PETCLINIC,
"java.petclinic.rename.acrossFiles",
"Rename: entity method used across controllers and tests",
{ file: `${PET}/owner/Owner.java`, line: "public void addVisit(Integer petId, Visit visit) {", word: "addVisit" },
"recordVisit",
),
];
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { compare, toolDrift } from "./golden.ts";
describe("compare", () => {
test("a perfect answer", () => {
expect(compare({ usages: ["a:1", "b:2"] }, { usages: ["b:2", "a:1"] })).toEqual({
recall: 1,
precision: 1,
missing: [],
extra: [],
});
});
test("dropped and extra results lower recall and precision", () => {
const c = compare({ usages: ["a:1", "c:3"] }, { usages: ["a:1", "b:2"] });
expect(c.recall).toBe(0.5);
expect(c.precision).toBe(0.5);
expect(c.missing).toEqual(["usages: b:2"]);
expect(c.extra).toEqual(["usages: c:3"]);
});
test("groups are part of the identity", () => {
expect(compare({ read: ["a:1"] }, { write: ["a:1"] }).recall).toBe(0);
});
test("nothing expected", () => {
expect(compare({}, {})).toMatchObject({ recall: 1, precision: 1 });
expect(compare({ usages: ["a:1"] }, {})).toMatchObject({ recall: 1, precision: 0 });
});
test("nothing found", () => {
expect(compare({}, { usages: ["a:1"] })).toMatchObject({ recall: 0, precision: 0 });
});
});
test("toolDrift lists tools that changed since the recording", () => {
expect(toolDrift({ tools: { jdtls: "1.63.0", java: "21" }, groups: {} }, { jdtls: "1.64.0", java: "21" })).toEqual([
"jdtls: 1.63.0 → 1.64.0",
]);
});
+62
View File
@@ -0,0 +1,62 @@
/**
* Golden results: what a scenario found when a person last confirmed it, keyed to the
* language server and JDK that found it. Compared as sets (recall / precision), not by order.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
/** Result group → items (e.g. `src/…/OrderBook.java:14`); repeated items count once. */
export type ResultSet = Record<string, string[]>;
export interface Golden {
/** Tool versions the results were recorded with (`jdtls`, `java`). */
tools: Record<string, string>;
groups: ResultSet;
}
export interface Comparison {
recall: number;
precision: number;
/** In the golden, not found (`group: item`). */
missing: string[];
/** Found, not in the golden. */
extra: string[];
}
const keys = (set: ResultSet) => new Set(Object.entries(set).flatMap(([g, items]) => items.map((i) => `${g}: ${i}`)));
export function compare(found: ResultSet, golden: ResultSet): Comparison {
const got = keys(found);
const want = keys(golden);
const missing = [...want].filter((k) => !got.has(k));
const extra = [...got].filter((k) => !want.has(k));
const hit = want.size - missing.length;
return {
// Nothing expected and nothing found is a perfect answer.
recall: want.size ? hit / want.size : 1,
precision: got.size ? hit / got.size : want.size ? 0 : 1,
missing: missing.sort(),
extra: extra.sort(),
};
}
export function readGolden(file: string): Golden | null {
return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as Golden) : null;
}
export function writeGolden(file: string, golden: Golden) {
mkdirSync(dirname(file), { recursive: true });
const sorted = Object.fromEntries(
Object.entries(golden.groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([g, items]) => [g, [...new Set(items)].sort()]),
);
writeFileSync(file, JSON.stringify({ tools: golden.tools, groups: sorted }, null, 2) + "\n");
}
/** Tools that differ between the golden's recording and this run (`name: was → now`). */
export function toolDrift(golden: Golden, tools: Record<string, string>): string[] {
return Object.entries(golden.tools)
.filter(([k, v]) => tools[k] !== undefined && tools[k] !== v)
.map(([k, v]) => `${k}: ${v} → ${tools[k]}`);
}
+86
View File
@@ -0,0 +1,86 @@
/** The machine and tools a report was measured with: a baseline only holds for the same ones. */
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { arch, cpus, platform, release, totalmem } from "node:os";
import { dirname, join } from "node:path";
export interface Fingerprint {
cpu: string;
cores: number;
memoryGb: number;
os: string;
}
export function fingerprint(): Fingerprint {
return {
cpu: cpus()[0]?.model.trim() ?? "unknown",
cores: cpus().length,
memoryGb: Math.round(totalmem() / 2 ** 30),
os: `${platform()} ${release()} ${arch()}`,
};
}
export function sameMachine(a: Fingerprint | undefined, b: Fingerprint): boolean {
return !!a && a.cpu === b.cpu && a.cores === b.cores && a.memoryGb === b.memoryGb && a.os === b.os;
}
/** `KEY=value` lines (`#` comments), as GitHub Actions' `$GITHUB_ENV` takes them; keys lower-cased. */
export function pinnedTools(file: string): Record<string, string> {
if (!existsSync(file)) return {};
const out: Record<string, string> = {};
for (const line of readFileSync(file, "utf8").split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(\S+)/.exec(line);
if (m) out[m[1]!.toLowerCase()] = m[2]!;
}
return out;
}
function which(cmd: string): string | null {
try {
const found = execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { encoding: "utf8" })
.split("\n")[0]!
.trim();
return found ? realpathSync(found) : null;
} catch {
return null;
}
}
/** jdtls's version from its core bundle (`org.eclipse.jdt.ls.core_1.63.0.202610191322.jar` → `1.63.0-202610191322`). */
function jdtlsVersion(): string | null {
const bin = which("jdtls");
if (!bin) return null;
for (let dir = dirname(bin), i = 0; i < 4; dir = dirname(dir), i++) {
for (const plugins of [join(dir, "plugins"), join(dir, "libexec", "plugins")]) {
if (!existsSync(plugins)) continue;
const core = readdirSync(plugins).find((f) => /^org\.eclipse\.jdt\.ls\.core_.*\.jar$/.test(f));
const m = core && /_(\d+\.\d+\.\d+)\.(\d+)\.jar$/.exec(core);
if (m) return `${m[1]}-${m[2]}`;
}
}
return null;
}
/**
* The JDK projects compile against, whose sources library navigation opens: `JAVA_HOME`'s (it
* wins for its Java version; jdtls itself runs on a newer one when it needs to), else the `java`
* on PATH. Runtime version and vendor (`-XshowSettings` prints to stderr).
*/
function javaVersion(): string | null {
const home = process.env.JAVA_HOME;
const java = home ? join(home, "bin", process.platform === "win32" ? "java.exe" : "java") : "java";
const r = spawnSync(java, ["-XshowSettings:properties", "-version"], { encoding: "utf8" });
const props = `${r.stdout ?? ""}${r.stderr ?? ""}`;
const version = /java\.runtime\.version = (\S+)/.exec(props)?.[1];
const vendor = /java\.vendor = (.+)/.exec(props)?.[1]?.trim();
return version ? `${version}${vendor ? ` (${vendor})` : ""}` : null;
}
export function toolVersions(): Record<string, string> {
const tools: Record<string, string> = {};
const jdtls = jdtlsVersion();
if (jdtls) tools.jdtls = jdtls;
const java = javaVersion();
if (java) tools.java = java;
return tools;
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Timing one user action in the page: the action is run, then every animation frame checks
* what the user would see. A condition true at the start of a frame is painted by it.
*/
import type { Page } from "@playwright/test";
/**
* A check of the page, run in it every frame: self-contained (it is re-created from its
* source), given the arguments passed to `track` and the editor state before the action.
*/
export type Check<A> = (arg: A, before: EditorSnapshot | null) => boolean;
export interface EditorSnapshot {
path: string;
head: number;
line: number;
word: string;
}
export interface Tracked {
/** Something visible changed in answer to the action (popup, hint, spinner, caret / editor moved). */
feedbackMs: number;
/** The first result is shown. */
firstResultMs: number;
/** The final result is shown and nothing is loading. */
completeMs: number;
}
export interface TrackSpec<A> {
/** The action run (`actionArg` its argument), the file opened (`openFile`), or a check-like step in the page (`run`). */
action?: string;
actionArg?: unknown;
openFile?: string;
run?: (arg: A) => unknown;
arg: A;
first: Check<A>;
done: Check<A>;
/** Extra feedback beyond the default (popup, hint, status spinner, editor or caret changed). */
feedback?: Check<A>;
/**
* For results with no loading indicator (gutter markers): done once `done` holds and
* `signature` has not changed for `settleMs`; complete is when it last changed.
*/
signature?: (arg: A) => unknown;
settleMs?: number;
timeoutMs: number;
}
export class TimeoutError extends Error {
readonly partial: Partial<Tracked>;
constructor(message: string, partial: Partial<Tracked>) {
super(message);
this.partial = partial;
}
}
export async function track<A>(page: Page, spec: TrackSpec<A>): Promise<Tracked> {
const r = await page.evaluate(
({ action, actionArg, openFile, run, arg, first, done, feedback, signature, settleMs, timeoutMs }) => {
const w = window as unknown as {
__openideaPerf: {
runAction(id: string, arg?: unknown): Promise<boolean>;
openFile(path: string): Promise<void>;
editorState(): EditorSnapshot | null;
};
};
const make = (src: string | null) =>
src
? (new Function("arg", "before", `return (${src})(arg, before)`) as (a: unknown, b: unknown) => boolean)
: null;
const checks = { first: make(first)!, done: make(done)!, feedback: make(feedback) };
const sign = signature
? (new Function("arg", `return JSON.stringify((${signature})(arg))`) as (a: unknown) => string)
: null;
let lastSig: string | null = null;
let lastChange = 0;
const before = w.__openideaPerf.editorState();
const visible = (sel: string) =>
[...document.querySelectorAll(sel)].some((el) => (el as HTMLElement).getClientRects().length > 0);
const anyFeedback = () => {
if (visible('.cm-lsp-hint, [data-testid="usages-popup"], [role="dialog"], [role="listbox"]')) return true;
const now = w.__openideaPerf.editorState();
if ((now?.path ?? null) !== (before?.path ?? null) || (now && before && now.head !== before.head)) return true;
return checks.feedback?.(arg, before) ?? false;
};
const times: { feedbackMs?: number; firstResultMs?: number; completeMs?: number } = {};
const t0 = performance.now();
let failed: unknown = null;
const started = run
? new Promise((resolve) => resolve(make(run)!(arg, before)))
: openFile
? w.__openideaPerf.openFile(openFile)
: w.__openideaPerf.runAction(action!, actionArg);
void started.catch((e: unknown) => (failed = e));
return new Promise<{ times: typeof times; timedOut: boolean; error: string | null }>((resolve) => {
const tick = () => {
const t = performance.now() - t0;
try {
if (times.feedbackMs === undefined && anyFeedback()) times.feedbackMs = t;
if (times.firstResultMs === undefined && checks.first(arg, before)) times.firstResultMs = t;
if (sign) {
const sig = sign(arg);
if (sig !== lastSig) {
lastSig = sig;
lastChange = t;
}
}
const settled = !sign || t - lastChange >= (settleMs ?? 0);
if (times.firstResultMs !== undefined && settled && checks.done(arg, before)) {
// Session.reset waits out the time in which invoking it again widens it.
if (action === "find.showUsages")
(window as unknown as { __openideaEvalShowUsagesAt?: number }).__openideaEvalShowUsagesAt =
performance.now();
times.completeMs = sign ? Math.max(lastChange, times.firstResultMs) : t;
times.feedbackMs ??= t;
return resolve({ times, timedOut: false, error: null });
}
} catch (e) {
return resolve({ times, timedOut: false, error: String(e) });
}
if (failed) return resolve({ times, timedOut: false, error: String((failed as Error).message ?? failed) });
if (t > timeoutMs) return resolve({ times, timedOut: true, error: null });
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
},
{
action: spec.action,
actionArg: spec.actionArg,
openFile: spec.openFile,
run: spec.run?.toString() ?? null,
arg: spec.arg,
first: spec.first.toString(),
done: spec.done.toString(),
feedback: spec.feedback?.toString() ?? null,
signature: spec.signature?.toString() ?? null,
settleMs: spec.settleMs,
timeoutMs: spec.timeoutMs,
},
);
const what = spec.action ?? (spec.openFile ? `open ${spec.openFile}` : "step");
if (r.error) throw new Error(`${what}: ${r.error}`);
if (r.timedOut) {
const seen =
r.times.firstResultMs !== undefined
? "a first result but no final one"
: r.times.feedbackMs !== undefined
? "feedback but no result"
: "nothing";
throw new TimeoutError(`${what} timed out after ${spec.timeoutMs} ms (${seen})`, r.times);
}
return r.times as Tracked;
}
+70
View File
@@ -0,0 +1,70 @@
/** Where a scenario's project comes from: `fixtures/<name>`, or an open-source project pinned in `evals/projects.json`. */
import { execFileSync, spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
interface Pinned {
repo: string;
commit: string;
remove?: string[];
prepare?: string[];
}
function pinned(repoRoot: string, name: string): Pinned | null {
const all = JSON.parse(readFileSync(join(repoRoot, "evals", "projects.json"), "utf8")) as Record<string, Pinned>;
return all[name] ?? null;
}
const quiet = { stdio: "ignore" as const };
/** The pinned checkout, cloned and prepared once; returns its directory. */
function checkout(repoRoot: string, name: string, p: Pinned): string {
const dir = join(repoRoot, "target", "eval", "sources", name);
if (!existsSync(join(dir, ".git"))) {
console.log(`Cloning ${p.repo}…`);
mkdirSync(join(dir, ".."), { recursive: true });
execFileSync("git", ["clone", "-q", "--filter=blob:none", p.repo, dir], quiet);
}
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim();
if (head !== p.commit) {
const has = spawnSync("git", ["cat-file", "-e", `${p.commit}^{commit}`], { cwd: dir }).status === 0;
if (!has) execFileSync("git", ["fetch", "-q", "origin", p.commit], { cwd: dir, ...quiet });
execFileSync("git", ["checkout", "-q", "--detach", p.commit], { cwd: dir, ...quiet });
}
const marker = join(repoRoot, "target", "eval", "sources", `.${name}.prepared`);
if (p.prepare && (!existsSync(marker) || readFileSync(marker, "utf8") !== p.commit)) {
console.log(`Preparing ${name}: ${p.prepare.join(" ")}…`);
const r = spawnSync(p.prepare[0]!, p.prepare.slice(1), { cwd: dir, stdio: ["ignore", "ignore", "inherit"] });
if (r.status !== 0) throw new Error(`preparing ${name} failed: ${p.prepare.join(" ")}`);
writeFileSync(marker, p.commit);
}
return dir;
}
/**
* A fresh copy of `name` at `dest`, as its own git repository (edits and IDE state of an
* earlier session don't carry over; it isn't part of the checkout `target/` sits in).
*/
export function copyProject(repoRoot: string, name: string, dest: string) {
const fixture = join(repoRoot, "fixtures", name);
const p = existsSync(fixture) ? null : pinned(repoRoot, name);
if (!p && !existsSync(fixture)) throw new Error(`no fixtures/${name} and no ${name} in evals/projects.json`);
const src = p ? checkout(repoRoot, name, p) : fixture;
rmSync(dest, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
mkdirSync(join(dest, ".."), { recursive: true });
cpSync(src, dest, { recursive: true, filter: (f) => !/[\\/](\.git|target|build|\.gradle|\.openidea)$/.test(f) });
for (const r of p?.remove ?? []) rmSync(join(dest, r), { recursive: true, force: true });
const env = {
...process.env,
GIT_AUTHOR_NAME: "eval",
GIT_AUTHOR_EMAIL: "eval@localhost",
GIT_COMMITTER_NAME: "eval",
GIT_COMMITTER_EMAIL: "eval@localhost",
};
for (const args of [
["init", "-q"],
["add", "-A"],
["commit", "-q", "--no-verify", "-m", "eval project"],
])
execFileSync("git", args, { cwd: dest, env, ...quiet });
}
+35
View File
@@ -0,0 +1,35 @@
/** What an eval scenario is: a project, a step the user takes, and what they should see. */
import type { ResultSet } from "./golden.ts";
import type { Tracked } from "./measure.ts";
import type { Session } from "./session.ts";
import type { Condition } from "./verdict.ts";
export interface Measured {
timing: Tracked;
results?: ResultSet;
/** Why the answer is weaker than a semantic one, when the UI says so. */
degraded?: string | null;
}
export interface EvalScenario {
/** Stable id: `<language>.<group>.<case>`, used by `--only`, goldens and budgets. */
id: string;
title: string;
/** `fixtures/<fixture>`. */
fixture: string;
/** The language server whose readiness the warm runs wait for. */
server: string;
conditions: Condition[];
/** Brings the idle workbench to where the step starts (open a file, place the caret). */
setup(s: Session): Promise<void>;
/** The measured step. */
step(s: Session): Promise<Measured>;
}
/** Project-relative path, or a library class's URI without its machine-specific query. */
export function normalizePath(root: string, path: string): string {
if (path.startsWith(root + "/")) return path.slice(root.length + 1);
const uri = /^[a-z]+:\/\//.exec(path);
if (uri) return path.replace(/[?#].*$/, "");
return path;
}
+403
View File
@@ -0,0 +1,403 @@
/**
* One eval session: a release `openidea-server --stdio` with its own home, and the production
* workbench in Chromium connected to it as remote development connects a window (the Tauri
* runtime from `smoke/tauriShim.ts`, commands and notifications relayed by Playwright).
*/
import { mkdirSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, sep } from "node:path";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { installInPage } from "../../perf/lib/inpage.ts";
import { Server } from "../../smoke/server.ts";
import { copyProject } from "./projects.ts";
import { installTauriShim, type ShimWindow } from "../../smoke/tauriShim.ts";
/** How long after its answer Show Usages invoked again widens it (`HINT_MS` in code-insight/usages.ts), plus a margin. */
const SHOW_USAGES_HINT_MS = 4200;
export interface IpcCall {
cmd: string;
/** Milliseconds since the session started. */
start: number;
ms: number;
ok: boolean;
/** JSON length of the result (or error). */
bytes: number;
/** Array length of the result, when it is one. */
items?: number;
}
export interface TaskSpan {
id: number;
title: string;
start: number;
end: number | null;
}
/** What the server did, for diagnostics: every command, background task and server status. */
export class Timeline {
readonly t0 = Date.now();
readonly calls: IpcCall[] = [];
readonly tasks = new Map<number, TaskSpan>();
readonly servers: Array<{ at: number; name: string; status: string; message: string | null }> = [];
/** Last time anything happened (a call ended, a task or status changed). */
lastActivity = 0;
inFlight = 0;
now() {
return Date.now() - this.t0;
}
onEvent(event: { type: string } & Record<string, unknown>) {
if (event.type === "task") {
const id = event.id as number;
const span = this.tasks.get(id) ?? { id, title: String(event.title), start: this.now(), end: null };
if (event.done) span.end = this.now();
this.tasks.set(id, span);
this.lastActivity = this.now();
} else if (event.type === "languageServer") {
this.servers.push({
at: this.now(),
name: String(event.name),
status: String(event.status),
message: (event.message as string | null) ?? null,
});
this.lastActivity = this.now();
}
}
runningTasks(): TaskSpan[] {
return [...this.tasks.values()].filter((t) => t.end === null);
}
serverStatus(name: string): string | null {
for (let i = this.servers.length - 1; i >= 0; i--)
if (this.servers[i]!.name === name) return this.servers[i]!.status;
return null;
}
/** The calls and tasks overlapping `[from, to]` (session milliseconds). */
window(from: number, to: number) {
return {
calls: this.calls.filter((c) => c.start <= to && c.start + c.ms >= from),
tasks: [...this.tasks.values()].filter((t) => t.start <= to && (t.end ?? Infinity) >= from),
};
}
}
export interface SessionOptions {
/** `fixtures/<name>` or a project pinned in `evals/projects.json`. */
fixture: string;
/** Where the project copy and the server's home go. */
workDir: string;
/** Keep the server's home (installed tools, language server workspaces) from an earlier session. */
keepHome: boolean;
/** `OPENIDEA_LOG` for the server. */
log?: string;
}
export class Session {
readonly timeline = new Timeline();
readonly root: string;
readonly home: string;
page!: Page;
private context!: BrowserContext;
private server!: Server;
private relayQueue: Promise<unknown> = Promise.resolve();
private pendingNotes: Array<[kind: "channel" | "event", a: unknown, b: unknown]> = [];
/** The last answer to each command that succeeded, for what the UI only summarises. */
readonly lastAnswer = new Map<string, unknown>();
/** Editors changed by a scenario: path → the text to put back. */
private readonly edited = new Map<string, string>();
readonly pageErrors: string[] = [];
private constructor(opts: SessionOptions) {
this.root = join(opts.workDir, "projects", opts.fixture);
this.home = join(opts.workDir, "home");
}
static async start(browser: Browser, url: string, repoRoot: string, opts: SessionOptions): Promise<Session> {
const s = new Session(opts);
copyProject(repoRoot, opts.fixture, s.root);
if (!opts.keepHome) rmSync(s.home, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
mkdirSync(s.home, { recursive: true });
s.server = await Server.start(s.home, opts.log ? { OPENIDEA_LOG: opts.log } : {});
s.server.onEvent = (target, event) => {
if (target !== null && target !== "main") return;
s.timeline.onEvent(event as { type: string });
s.notify("event", target, event);
};
s.server.onChannel = (id, data) => s.notify("channel", id, data);
await s.server.invoke("project_trust_set", { path: s.root, trusted: true, folder: false });
s.context = await browser.newContext({
baseURL: url,
viewport: { width: 1400, height: 900 },
deviceScaleFactor: 1,
});
await s.context.exposeFunction("__openideaRelay", async (cmd: string, args: unknown) => {
const start = s.timeline.now();
s.timeline.inFlight++;
try {
const value = await s.server.invoke(cmd, args);
s.record(cmd, start, true, value);
return { ok: value ?? null };
} catch (err) {
s.record(cmd, start, false, err);
return { err };
} finally {
s.timeline.inFlight--;
s.timeline.lastActivity = s.timeline.now();
}
});
await s.context.addInitScript(installTauriShim, {
directories: { 12: tmpdir(), 21: homedir() },
sep: sep as "/" | "\\",
});
await s.context.addInitScript(() => {
const w = window as unknown as ShimWindow & {
__openideaRelay(cmd: string, args: unknown): Promise<{ ok: unknown } | { err: unknown }>;
};
w.__openideaForward = async (cmd, args) => {
const r = await w.__openideaRelay(cmd, args);
if ("err" in r) throw r.err;
return r.ok;
};
});
await s.context.addInitScript(installInPage);
s.page = await s.context.newPage();
s.page.on("pageerror", (e) => s.pageErrors.push(e.message));
if (process.env.OPENIDEA_EVAL_DEBUG) {
s.page.on("console", (m) => console.log("[page]", m.type(), m.text().slice(0, 300)));
s.page.on("pageerror", (e) => console.log("[pageerror]", e.message));
}
await s.page.goto("/");
await s.page.waitForFunction(() => "__openideaPerf" in window && "__openideaShim" in window);
await s.page.evaluate(
(root) =>
(window as unknown as { __openideaPerf: { openProject(p: string): Promise<void> } }).__openideaPerf.openProject(
root,
),
s.root,
);
await s.page.waitForSelector('[role="tree"][aria-label="Project"] [role="treeitem"]', { timeout: 60_000 });
return s;
}
private record(cmd: string, start: number, ok: boolean, value: unknown) {
let bytes = 0;
try {
bytes = JSON.stringify(value ?? null).length;
} catch {
// not serialisable: size unknown
}
const items = Array.isArray(value)
? value.length
: value && typeof value === "object" && Array.isArray((value as { locations?: unknown }).locations)
? (value as { locations: unknown[] }).locations.length
: undefined;
this.timeline.calls.push({ cmd, start, ms: this.timeline.now() - start, ok, bytes, items });
if (ok) this.lastAnswer.set(cmd, value);
}
/** Delivers server notifications in order, batched per turn of the event loop. */
private notify(kind: "channel" | "event", a: unknown, b: unknown) {
this.pendingNotes.push([kind, a, b]);
if (this.pendingNotes.length > 1) return;
this.relayQueue = this.relayQueue
.then(() => new Promise((r) => setImmediate(r)))
.then(() => {
const batch = this.pendingNotes;
this.pendingNotes = [];
return this.page
?.evaluate((notes) => {
const shim = (window as unknown as ShimWindow).__openideaShim;
for (const [kind, a, b] of notes) {
if (kind === "channel") shim.channel(a as number, b);
else shim.event(a as string | null, b);
}
}, batch)
.catch(() => {});
});
}
runAction(id: string, arg?: unknown): Promise<boolean> {
return this.page.evaluate(
async ({ id, arg }) => {
const w = window as unknown as {
__openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> };
__openideaEvalShowUsagesAt?: number;
};
const ran = await w.__openideaPerf.runAction(id, arg);
if (id === "find.showUsages") w.__openideaEvalShowUsagesAt = performance.now();
return ran;
},
{ id, arg },
);
}
/** Opens `rel` (project-relative, or a URI) in an editor and waits until its text is shown. */
async openFile(rel: string) {
const path = /^[a-z]+:\/\//.test(rel) ? rel : join(this.root, rel);
const bare = rel.replace(/[?#].*$/, "");
const name = bare.slice(bare.lastIndexOf("/") + 1);
await this.page.evaluate(
async ({ path, name }) => {
const w = window as unknown as {
__perf: { waitShown(s: string, t: string | null, ms: number): Promise<number> };
__openideaPerf: { openFile(p: string): Promise<void> };
};
await w.__openideaPerf.openFile(path);
await w.__perf.waitShown('[role="tab"][aria-selected="true"]', name, 30_000);
await w.__perf.waitShown(".cm-content .cm-line", null, 30_000);
},
{ path, name },
);
}
/**
* Goes to the declaration of `word` on `line` of the active editor and waits until another
* file shows (a library class: its `jdt://` URI, which carries what only the server knows).
*/
async declarationOf(line: string, word: string): Promise<string> {
await this.caretAt(line, word);
type Probe = { __openideaPerf: { editorState(): { path: string } | null } };
const from = await this.page.evaluate(() => (window as unknown as Probe).__openideaPerf.editorState()?.path);
await this.runAction("navigate.declaration");
const handle = await this.page.waitForFunction(
(from) => {
const path = (window as unknown as Probe).__openideaPerf.editorState()?.path;
return path && path !== from && document.querySelector(".cm-content .cm-line") ? path : null;
},
from,
{ timeout: 30_000 },
);
return (await handle.jsonValue()) as string;
}
/** Puts the caret inside `word` on the active editor's first line containing `line` (both literal). */
async caretAt(line: string, word: string) {
const text = await this.editorText();
const at = text.indexOf(line);
if (at < 0) throw new Error(`no line containing ${JSON.stringify(line)} in the active editor`);
const inLine = line.indexOf(word);
if (inLine < 0) throw new Error(`${JSON.stringify(word)} is not in ${JSON.stringify(line)}`);
// Inside the word, as a click on it would put it.
const target = at + inLine + Math.floor(word.length / 2);
await this.page.evaluate(
(o) => (window as unknown as { __openideaPerf: { setCursor(o: number): void } }).__openideaPerf.setCursor(o),
target,
);
}
/** The active editor's whole text. */
editorText(): Promise<string> {
return this.page.evaluate(() => {
const text = (
window as unknown as { __openideaPerf: { editorText(): string | null } }
).__openideaPerf.editorText();
if (text === null) throw new Error("no active editor");
return text;
});
}
/** Keeps the active editor's text, for `reset` to put back whatever the scenario changes. */
async keepText() {
const { path, text } = await this.page.evaluate(() => {
const probe = (
window as unknown as {
__openideaPerf: { editorState(): { path: string } | null; editorText(): string | null };
}
).__openideaPerf;
return { path: probe.editorState()?.path, text: probe.editorText() };
});
if (path === undefined || text === null) throw new Error("no active editor");
if (!this.edited.has(path)) this.edited.set(path, text);
}
/** Inserts `text` after the first `after` in the active editor; the offset after it. */
async edit(after: string, text: string): Promise<number> {
await this.keepText();
return this.page.evaluate(
({ after, text }) =>
(
window as unknown as { __openideaPerf: { insertAfter(a: string, t: string): number } }
).__openideaPerf.insertAfter(after, text),
{ after, text },
);
}
private async restoreEdits() {
for (const [path, text] of this.edited) {
await this.page.evaluate(
async ({ path, text }) => {
const probe = (
window as unknown as {
__openideaPerf: {
openFile(p: string): Promise<void>;
editorState(): { path: string } | null;
editorText(): string | null;
setText(t: string): void;
};
}
).__openideaPerf;
await probe.openFile(path);
while (probe.editorState()?.path !== path) await new Promise(requestAnimationFrame);
if (probe.editorText() !== text) probe.setText(text);
},
{ path, text },
);
await this.runAction("file.save");
}
this.edited.clear();
}
/** Waits until `server` is ready and nothing has happened for `quietMs`: no task, no command in flight. */
async settle(server: string, quietMs = 2000, timeoutMs = 5 * 60_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const t = this.timeline;
const quiet =
t.serverStatus(server) === "ready" &&
t.runningTasks().length === 0 &&
t.inFlight === 0 &&
t.now() - t.lastActivity >= quietMs;
if (quiet) return;
if (Date.now() > deadline) {
throw new Error(
`the IDE did not settle within ${timeoutMs / 1000}s: ${server} ${t.serverStatus(server) ?? "not started"}, ` +
`tasks ${JSON.stringify(t.runningTasks().map((x) => x.title))}, ${t.inFlight} command(s) in flight`,
);
}
await new Promise((r) => setTimeout(r, 200));
}
}
/** Back to an idle workbench: popups dismissed, edits undone, every editor closed. */
async reset() {
// Show Usages invoked again within 4 s of its hint widens the previous search: wait it out.
const since = await this.page.evaluate(() => {
const at = (window as unknown as { __openideaEvalShowUsagesAt?: number }).__openideaEvalShowUsagesAt;
return at === undefined ? Infinity : performance.now() - at;
});
if (since < SHOW_USAGES_HINT_MS) await this.page.waitForTimeout(SHOW_USAGES_HINT_MS - since);
await this.page.keyboard.press("Escape");
await this.page.keyboard.press("Escape");
await this.restoreEdits();
await this.runAction("editor.closeAllTabs");
await this.page.waitForFunction(() => !document.querySelector('[role="tab"][aria-selected="true"]'), undefined, {
timeout: 10_000,
});
}
serverLog(): string {
return this.server.log();
}
async close() {
await this.context?.close().catch(() => {});
await this.server?.close();
}
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, test } from "vitest";
import { judge, table, type Budgets, type Outcome } from "./verdict.ts";
const budgets: Budgets = {
regressionTolerance: 0.2,
regressionFloorMs: 200,
defaults: { feedbackMs: 100, recall: 1, precision: 1 },
scenarios: { usages: { completeMs: 2000 }, "usages@cold": { completeMs: 10_000 } },
};
const outcome = (o: Partial<Outcome>): Outcome => ({
scenario: "usages",
title: "Show Usages",
condition: "warm",
metrics: { feedbackMs: 40, firstResultMs: 300, completeMs: 500, recall: 1, precision: 1 },
samples: {},
results: { usages: ["a:1"] },
comparison: { recall: 1, precision: 1, missing: [], extra: [] },
...o,
});
const repro = () => "pnpm eval --only usages";
describe("judge", () => {
test("within budgets and matching the golden passes", () => {
const [v] = judge([outcome({})], budgets, null, repro);
expect(v).toMatchObject({ id: "usages@warm", status: "pass", failures: [], repro: "pnpm eval --only usages" });
});
test("slow feedback, dropped results and silent degradation each fail", () => {
const [v] = judge(
[
outcome({
metrics: { feedbackMs: 250, firstResultMs: 300, completeMs: 2500, recall: 0.5, precision: 1 },
comparison: { recall: 0.5, precision: 1, missing: ["usages: b:2"], extra: [] },
degraded: "Based on syntax",
}),
],
budgets,
null,
repro,
);
expect(v!.status).toBe("fail");
expect(v!.failures.map((f) => [f.kind, f.metric])).toEqual([
["degraded", undefined],
["budget", "feedbackMs"],
["correctness", "recall"],
["budget", "completeMs"],
]);
expect(v!.failures.find((f) => f.metric === "recall")!.detail).toContain("usages: b:2");
});
test("condition budgets override scenario budgets", () => {
const [v] = judge(
[outcome({ condition: "cold", metrics: { feedbackMs: 40, completeMs: 5000, recall: 1, precision: 1 } })],
budgets,
null,
repro,
);
expect(v!.status).toBe("pass");
});
test("timeouts and missing goldens fail", () => {
const [timedOut, noGolden] = judge(
[outcome({ error: "find.showUsages timed out after 30000 ms" }), outcome({ comparison: undefined })],
budgets,
null,
repro,
);
expect(timedOut!.failures[0]!.kind).toBe("timeout");
expect(noGolden!.failures[0]!.kind).toBe("golden");
});
test("regressions need both the relative tolerance and the absolute floor", () => {
const baseline = [outcome({ metrics: { feedbackMs: 40, firstResultMs: 300, completeMs: 500 } })];
const [v] = judge(
[outcome({ metrics: { feedbackMs: 90, firstResultMs: 450, completeMs: 1200, recall: 1, precision: 1 } })],
budgets,
baseline,
repro,
);
expect(v!.status).toBe("pass");
expect(v!.regressions.map((r) => r.metric)).toEqual(["completeMs"]);
expect(table([v!], false)).toContain("regressed: completeMs");
});
});
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more