mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 10:41:28 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee0952e32e |
No files matched your search
@@ -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.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/eval
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -40,6 +40,7 @@ CLAUDE.local.md
|
||||
.claude/skills/*
|
||||
!.claude/skills/verify
|
||||
!.claude/skills/issue-fix
|
||||
!.claude/skills/eval
|
||||
|
||||
# Local reference screenshots
|
||||
pycharm-screenshots/
|
||||
@@ -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",
|
||||
|
||||
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
|
||||
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
|
||||
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
|
||||
for manual and automated testing.
|
||||
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
|
||||
`fixtures/` — sample projects per language for manual and automated testing.
|
||||
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
|
||||
preview with `npx mint dev` in `docs/`.
|
||||
|
||||
@@ -59,9 +59,12 @@ Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
Do not use Impeccable or its related skills in this repository, including globally installed copies.
|
||||
|
||||
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
|
||||
`issue-fix` takes an issue from reproduction to one PR.
|
||||
`issue-fix` takes an issue from reproduction to one PR;
|
||||
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -81,6 +84,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
benches vs budgets). Budgets: `perf/budgets.json`.
|
||||
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
|
||||
usages, gutter markers) in the production workbench against a release `openidea-server` and
|
||||
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
|
||||
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
|
||||
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
|
||||
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
|
||||
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
|
||||
|
||||
|
||||
@@ -1,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)
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -536,7 +536,6 @@ mod tests {
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"-Djacoco.skip=true",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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"]})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(¬ices, &sink, &reason);
|
||||
}
|
||||
let notes: Vec<_> = events
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
IdeEvent::Notify { level, title, body } => (*level, title.clone(), body.clone()),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
(NotifyLevel::Info, NOTICE_TITLE.to_string(), Some(STILL_IMPORTING.to_string())),
|
||||
(
|
||||
NotifyLevel::Info,
|
||||
NOTICE_TITLE.to_string(),
|
||||
Some("kotlin-lsp did not start: needs JDK 21".to_string())
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
];
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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]}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user