Compare commits

..
Author SHA1 Message Date
l0ng-ai cbcd1818a7 fix(plugins): accept empty navigation anchor ranges as positions
A document indexer that anchors a whole-file definition at the file's start
(an empty range at 0:0) had the whole document rejected with "invalid
navigation anchors". Empty ranges inside the document are now accepted:
Go to Declaration lands on them, while Find Usages and Rename skip them so
a rename never inserts the new name at that position. The error for a
document that is still rejected names the plugin and the file.
2026-11-12 00:11:01 +08:00
ntbang0901 c73d858895 feat(debugger): debug Kotlin with kotlin-lsp's debugger (#2651)
kotlin-debug-adapter (fwcd) is unmaintained: its evaluate only resolves plain local names and it reports breakpoints verified without checking. Kotlin debugging now uses the debugger inside JetBrains kotlin-lsp once its project import has finished, attached to the JVM the IDE starts through the existing JDWP link. kotlin-debug-adapter stays the fallback, with a notice saying why (not installed, still importing, import failed, cannot start).

kotlin-lsp's DAP quirks (step stop reasons, thread ids from earlier stops, frame names, file: URIs) are handled in one place, and its invalidated events reload the Variables pane and watches.

Co-authored-by: ntbang0901 <[email protected]>
2026-11-12 00:05:41 +08:00
l0ng-ai 6e5486586c fix(i18n): restore the comma missing after the local data directory strings in zh.json (#2771) 2026-11-12 00:03:13 +08:00
arookieofc 93642b3741 feat(settings): custom local data directory with one-click migration (#1722) (#2677) 2026-11-11 23:53:24 +08:00
Gabriel Gondim b06f2e27ea feat(settings): Color Scheme › Console Colors for the terminal's ANSI colors (#2751)
Settings › Editor › Color Scheme gets a Console Colors page, as in IntelliJ IDEA: the console background and the 16 ANSI colors, used by the terminal and consoles. A light IDE theme no longer forces a light terminal. IDEA's CONSOLE_* keys from .icls schemes and IntelliJ theme imports set the same colors.

Closes #2698

Co-authored-by: GGVidal <[email protected]>
2026-11-11 23:40:04 +08:00
Muuandmuutot 86f69b7fca feat(settings): complete the Python color page as PyCharm's full model (#2761)
The Python page of Settings › Editor › Color Scheme now has PyCharm's 38 attributes in its order and groups, all scoped to Python: an override lands in the editor block for Python files instead of recolouring every language, and unset values fall back to the shared scheme value. The seven attributes that had no default value (parentheses, brackets, braces, nested function, method call, type annotation, f-string format spec) now resolve, and .icls PY.* keys map to their Python attributes. Attribute ids and saved overrides are unchanged.

Co-authored-by: muutot <[email protected]>
2026-11-11 23:38:25 +08:00
Vadim LazukandInf1mum fefa135f5a feat(docker): pick an SSH configuration for TCP/SSH Docker connections (#2737)
The TCP / SSH host kind of a Docker connection now offers the IDE's SSH Configurations (with … to open Settings › Tools › SSH Configurations), as IntelliJ IDEA does. Picking a configuration with OpenSSH config authentication fills the Engine API URL with ssh://[user@]host[:port], keeping the alias so the system ssh docker runs applies the entry's key, user and jump hosts; an existing ssh:// URL shows its configuration again. Password and key-pair configurations are listed but explained as unusable for now, since docker over SSH goes through the system ssh.

Refs #939

Co-authored-by: Inf1mum <[email protected]>
2026-11-11 23:36:44 +08:00
Vadim LazukandInf1mum 8fe2d35d62 feat(editor): pair <> where a generic type list starts (#2738)
Under Smart Keys › Insert paired brackets, < now pairs into <> where the syntax tree says a type argument / parameter list starts, as in IntelliJ IDEA: after a type name or generic declaration name in Java, TypeScript, Kotlin and C#, and Rust types, fn names and turbofish ::<. Comparisons (a < b, i <= n, x << 2), tags and literals never pair; > steps over the inserted half and Tab leaves it. Languages without a rule don't pair.

Closes #2700

Co-authored-by: Inf1mum <[email protected]>
2026-11-11 23:34:24 +08:00
rohitkumardandaandrohitdanda 21046c3047 fix(platform): open only http(s) and mailto URLs externally (#2755)
openExternalUrl handed any string to the system opener, and plugins and rendered content (Markdown, notebook output, editor links) reach it with arbitrary input, so file: or app-launching schemes such as ms-msdt: and search-ms: could be passed to the OS. It now opens only http:, https: and mailto: URLs and ignores anything else, including malformed or whitespace-padded strings.

Co-authored-by: rohitdanda <[email protected]>
2026-11-11 23:32:18 +08:00
l0ng-ai 017561a20b fix(libraries): list the JDK for an .idea project only when it is a Java one (#2765)
A project without a build manifest got a JDK under External Libraries as soon
as it had an .idea folder, which GoLand, PyCharm and WebStorm write too: a Go
project showed < jdk-25 >. The folder now counts only when misc.xml names a
Java SDK or language level, or a module file is a JAVA_MODULE.
2026-11-11 23:31:36 +08:00
l0ng-ai d3a68b2754 fix(lsp): ask again when a server withdraws a rename while analysing (#2762)
A server still analysing an edit may answer textDocument/rename with
ContentModified (the Dart analysis server does when the document changed
under it). That maps to a cancellation, so the rename the user confirmed was
dropped without a word. The rename is now asked again a few times, 300 ms
apart, and if it keeps being withdrawn the error says so.
2026-11-11 23:28:22 +08:00
l0ng-ai e98a560523 fix(run): open .NET Project configurations as the form with a project field (#2757)
The .NET Project type had no way back from a command line to its fields, and a
new configuration starts without params, so the editor fell back to the plain
Command/Arguments form: there was no .NET project field, and Debug failed with
"Select a .NET project". dotnet run command lines the form can express now map
back to its fields, for new configurations and ones typed as a command.

Fixes #2733
2026-11-11 23:21:53 +08:00
l0ng-ai 6983e8ec2f feat(color-scheme): add a C# page to Settings › Editor › Color Scheme (#2756)
C# gets its own page like Go, Java, Kotlin and PHP: every Language Defaults
attribute can be overridden for C# editors only, with a C# preview. Language
sub-page ids are now slugged (C# → csharp): the '#' in editor.colorScheme.c#
made the page unreachable from the settings tree.
2026-11-11 23:17:46 +08:00
l0ng-ai dc355602a9 feat(http-client): go to a {{variable}}'s definition (#2754)
Navigate › Declaration (⌘-click, ⌘B) on {{name}} in a .http file now lists
its definitions in lookup order: the file's @name = … line, then the key in
each environment (and $shared) of the nearest http-client.private.env.json
and http-client.env.json. Dynamic variables ($uuid…) have none.
2026-11-11 23:13:42 +08:00
l0ng-ai 924fa9259d fix(plugins): refuse a command while its plugin waits for an answer in another window (#2753)
A plugin runs one call at a time, and a prompt waits for its answer without a
limit. A command run from another window while the plugin's prompt was open
queued silently behind it and ran only after the prompt was answered. It is
now refused with a message naming the reason, and the window that holds the
prompt is brought to the front.

Fixes #2673
2026-11-11 23:04:11 +08:00
l0ng-ai 24deeb0dbb fix(i18n): drop repeated zh catalog keys and test that every key appears once (#2752)
Merges had added eleven zh.json keys a second time (Docker, Shell, the language
names, Add Service ({kind}), Others, Servers). JSON.parse silently keeps the last
copy, so nothing failed. The catalogs test now reads each catalog file and fails
on a key listed twice.
2026-11-11 22:58:21 +08:00
renyun2andrenyun2 0ce7f6e430 feat(agents): expose Cursor effort and Fast as separate options (#2705)
Cursor's ACP agent only offers thinking effort and a separate Fast switch when the client advertises parameterizedModelPicker. OpenIDEA now advertises it, and when an ACP agent is picked in the Home composer before any session has reported its options, a short discovery session (session/new without MCP servers, killed afterwards or on timeout, once per agent per run) records its config options and effort levels. Effort and Fast show inside the model control, a new Cursor task defaults Fast to off, and config changes show immediately and roll back if the agent rejects them.

Co-authored-by: renyun2 <[email protected]>
2026-11-11 22:54:44 +08:00
misswellandmisswell 84fd0d0a60 fix(build): skip JaCoCo instrumentation in IDE compile builds (#2686)
Projects that bind jacoco-maven-plugin's offline instrument goal to process-classes had target/classes rewritten by the IDE's own Build/Rebuild and launch builds, so a later Debug/Run without the JaCoCo agent failed with NoClassDefFoundError. The Maven steps of Build and Rebuild now pass -Djacoco.skip=true; artifact (package) builds and coverage runs are unchanged, and Automatic launches stay incremental.

Fixes #2676

Co-authored-by: misswell <[email protected]>
2026-11-11 22:50:57 +08:00
Kamal ElFadlandelfadl 96c99e0396 feat(agents): add a command execution policy for ACP agents (#1165)
Settings › Agents › Command Execution Policy (global or per project) lists commands an agent may run without asking and commands it is never allowed to run. A permission request for a shell command is answered from the policy: a blocked pattern (glob, checked against the whole command and every part of a chain) rejects it; it is approved only when every part of a &&/||/;/|/& chain matches an allowed entry exactly or up to a word boundary and none uses command substitution, redirection, subshells, here-docs or leading variable assignments. Everything else prompts as before. The allowed list starts empty; the blocked list ships common destructive commands.

Closes #1152

Co-authored-by: elfadl <[email protected]>
2026-11-11 22:50:17 +08:00
arookieofcanda-rookie-of-C-language fd4838fc36 feat(agents): create, remove and import custom ACP agents (#2690)
Settings › Agents › Coding Agents can add a custom ACP agent (name, id, command, arguments, environment) without writing its agent file by hand, remove one, and import the agent_servers entries of a JetBrains acp.json. Built-in and ACP Registry ids are refused, ids are validated as file stems on save and on remove, and colliding or incomplete acp.json entries are skipped with a reason.

Closes #228

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:45:36 +08:00
arookieofcanda-rookie-of-C-language 4873905884 feat(agents): accept worktree task changes into the local checkout (#2704)
The Changes pane of a worktree task gets an Accept action next to Rollback: after confirmation it copies the task's changed files (all, or the selected ones) from the task worktree into the local checkout, uncommitted. A file the checkout has changed itself is left alone and listed in the notification instead of being overwritten; running and local tasks are refused.

Closes #1777

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:40:49 +08:00
arookieofcanda-rookie-of-C-language 6b37788971 test(lsp): go.mod/go.work reach gopls with the language id it understands (#2719)
Pins the go.mod serving contract: opening go.mod through LspManager with the bundled registry sends didOpen to gopls with languageId go.mod, scoped to the module root, and gopls's pushed diagnostics reach Problems. The gomod/gowork gopls entries now use the same initialization options as the go entry, so startup options no longer depend on which file launches the server first.

Refs #2248

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:33:33 +08:00
arookieofcanda-rookie-of-C-language 44bf648bee fix(tomcat): rebuild the WAR when web resources or dependency modules change (#2722)
The pre-launch up-to-date check only compared the WAR with the WAR module's own src and build files, so a save in a dependency module (mapper XML in a DAO module), in web content outside src (custom warSourceDirectory / webResources), or in any module when the config had no cwd (the check fell back to the project root) left the old WAR deployed. The check now resolves the WAR module from the selected artifact when cwd is missing, includes its web resource directories, and includes the sources and build files of every module in the build model.

Fixes #2685

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-11 22:32:36 +08:00
195 changed files with 7099 additions and 3956 deletions

No files matched your search

-83
View File
@@ -1,83 +0,0 @@
---
name: eval
description: Run the code-intelligence evals (`pnpm eval`), pick the worst failing scenario, diagnose it from the attached timeline, fix it, prove the fix with before / after numbers measured on this machine without regressing other scenarios, and open one PR. Use for "跑 eval", "优化 Java 代码智能", "eval 失败了", an issue labelled `eval`, or any change to Java navigation / usages / gutter markers that needs numbers.
---
# Eval loop: measure → fix → prove
`pnpm eval` drives the production workbench in Chromium against a release `openidea-server`
with real language servers (Java / jdtls for now) and judges every scenario on what the user
sees: time to feedback, to the first result, to the final one, and the results themselves vs
`evals/<language>/golden/`. One machine, before and after: numbers from different machines or
from the nightly host don't judge a fix.
## 0. Preconditions
- `jdtls` on PATH and `JAVA_HOME` pointing at a JDK of the pinned `JAVA` version (projects
compile against it, and the JDK scenarios' results are in its sources; jdtls itself runs on
a newer installed JDK when it needs one). Pinned versions: `.github/tool-versions.env`. A
mismatch prints a warning and the report records both: say so in the PR when it applies.
- First run builds `cargo build --release -p ide-host --bin openidea-server` and the perf
bundle; later iterations of a frontend-only change can use `--no-build` after a
`pnpm exec vite build --mode perf --outDir target/eval/dist`, a backend-only one after the
cargo build alone.
- Run evals one at a time on this machine, with nothing heavy (builds, other evals) beside them.
## 1. Baseline on the base commit
```sh
git switch --detach origin/main # or the fix branch's merge base
pnpm eval --save-baseline # target/eval/baseline.json, this machine only
```
Read `target/eval/report.json`. Each verdict has `status`, `failures` (`kind`: `error`,
`timeout`, `degraded`, `correctness`, `budget`, `golden`), `regressions`, `repro` (the command
that reruns it) and, for failures, `diagnostics`: every command sent to the server in the
slowest run's window (`cmd`, `start`, `ms`, `ok`, `bytes`, `items`), background tasks, server
status changes and the server's log tail.
## 2. Pick one problem
Worst first: wrong or missing results and silent degradation, then timeouts, then the
feedback budget (100 ms), then the slowest `completeMs`. One problem per PR. Say which
scenario and metric, with its value vs budget.
## 3. Diagnose from the timeline
- Commands in the window ordered by `start`: what ran, what waited on what, what's serial
that could overlap, which request returned many `items`/`bytes`, which failed.
- `feedbackMs` far above 16 ms with a fast `firstResultMs`: the UI shows nothing while it
waits (a hint or popup that only appears after a delay).
- A task still running across the window: background work competing with the request.
- For the frontend side rerun with `--trace` and open `target/eval/traces/<id>@<cond>.json`
in Chrome's Performance panel.
- Name the cause at class level (as in `issue-fix`): it usually affects sibling scenarios.
## 4. Fix, then rerun that scenario until it passes
```sh
pnpm eval --only <scenario> --condition <warm|cold> # the verdict's `repro`
```
Correctness is enforced, so a faster answer that drops, truncates or serves stale results
fails. Don't trade a budget miss for a degraded answer.
## 5. Full suite, then PR
```sh
pnpm eval # vs the baseline from step 1: no new failures or regressions
```
PR per `issue-fix` §6, plus in **How it was verified** the before / after table from
`target/eval/report.md` (both on this machine, commits named) and the tool versions line.
## Guard rails
- Goldens (`evals/*/golden/`) and budgets (`evals/budgets.json`) are human-owned. Don't edit
them, and don't run `--update-golden`, to make a scenario pass. When a golden looks wrong
(a tool bump, a deliberate behaviour change), stop and say so: a PR that touches them gets
`needs-decision`.
- Don't weaken a scenario's checks or timeouts in `evals/<language>/scenarios.ts` to pass.
Adding a scenario (for a case the sweep or a user found) is fine; record its golden with
`--update-golden --only <id>` and ask a person to review the file.
- A flaky timing (passes on rerun) is not a regression: rerun once before acting on one.
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/eval
-14
View File
@@ -1,14 +0,0 @@
# Language server and tool versions for Language smoke (.github/workflows/language-smoke.yml)
# and the code-intelligence evals (pnpm eval). Pinned so a release can't fail an unrelated
# PR or move a golden result; bump them deliberately (and re-record goldens: pnpm eval --update-golden).
VTSLS=0.3.0
TYPESCRIPT=6.0.3
BASEDPYRIGHT=1.40.2
RUFF=0.17.3
GOPLS=v0.24.0
JDTLS=1.63.0-202610191322
KOTLIN_LSP=263.7084.0
GRADLE=8.14.3
INTELEPHENSE=1.18.5
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
JAVA=21
+11 -4
View File
@@ -24,7 +24,6 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
- ".github/tool-versions.env"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
@@ -39,6 +38,16 @@ concurrency:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# Pinned so a server release can't fail an unrelated PR; bump them deliberately.
VTSLS: 0.3.0
TYPESCRIPT: 6.0.3
BASEDPYRIGHT: 1.40.2
RUFF: 0.17.3
GOPLS: v0.24.0
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -52,8 +61,6 @@ jobs:
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
# Tool versions, shared with the evals (pnpm eval).
- run: grep -E '^[A-Z0-9_]+=' .github/tool-versions.env >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with: { components: "rust-analyzer, rustfmt" }
# One build cache for every language; main saves it.
@@ -89,7 +96,7 @@ jobs:
- if: matrix.language == 'java' || matrix.language == 'kotlin'
uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "${{ env.JAVA }}" }
with: { distribution: temurin, java-version: "21" }
- if: matrix.language == 'java'
run: |
mkdir -p "$RUNNER_TEMP/jdtls"
-1
View File
@@ -40,7 +40,6 @@ CLAUDE.local.md
.claude/skills/*
!.claude/skills/verify
!.claude/skills/issue-fix
!.claude/skills/eval
# Local reference screenshots
pycharm-screenshots/
-1
View File
@@ -9,7 +9,6 @@
"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",
+3 -11
View File
@@ -24,8 +24,8 @@ Visual style: dark, low-contrast greys, one cool accent
Plugin docs: `plugins/README.md`, `plugins/themes.md`.
- `java/` — JDT LS extension bundles (`java-usages`, `kotlin-bridge`) and the patched
Lombok agent, embedded into the app by `ide-sdk` / `ide-tools`.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `evals/` — code-intelligence evals (`pnpm eval`).
`fixtures/` — sample projects per language for manual and automated testing.
- `e2e/` — Playwright UI tests (`pnpm e2e`). `fixtures/` — sample projects per language
for manual and automated testing.
- `docs/` — the Mintlify documentation site (`docs.json` navigation, `en/` and `zh/` pages);
preview with `npx mint dev` in `docs/`.
@@ -59,12 +59,9 @@ Unclear product decisions → `needs-decision` and stop.
## Skills
Do not use Impeccable or its related skills in this repository, including globally installed copies.
Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`verify` drives the running app through Tauri MCP and captures screenshot evidence;
`issue-fix` takes an issue from reproduction to one PR;
`eval` takes a failing code-intelligence eval to a fix with before / after numbers.
`issue-fix` takes an issue from reproduction to one PR.
## Commands
@@ -84,11 +81,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
benches vs budgets). Budgets: `perf/budgets.json`.
- `pnpm eval [--only <id>] [--runs n] [--save-baseline]` — Java code intelligence (navigation,
usages, gutter markers) in the production workbench against a release `openidea-server` and
jdtls: time to feedback / first result / complete, results vs `evals/<language>/golden/`.
Report: `target/eval/report.json`. Goldens and `evals/budgets.json` are changed by people only.
Tool versions: `.github/tool-versions.env` (shared with Language smoke).
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
+102
View File
@@ -0,0 +1,102 @@
//! A short-lived ACP session to learn models, effort levels and other config options
//! before the user's first task (Cursor and other ACP agents do not expose these in
//! `initialize` alone).
use super::{Conn, Dialect};
use crate::config;
use crate::model::AgentConfigOption;
use crate::provider::ProviderSpec;
use crate::{Shared, client_info};
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const DISCOVER_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(3) } else { Duration::from_secs(120) };
/// Opens `session/new`, reads `configOptions`, and records per-model `thought_level` choices.
pub(crate) async fn run(shared: &Arc<Shared>, spec: &ProviderSpec) -> Result<(), String> {
let root = shared.paths.project_root.clone();
let launch = spec.clone();
let launch = tokio::task::spawn_blocking({
let root = root.clone();
move || launch.resolve_for_launch(&root)
})
.await
.map_err(|e| e.to_string())?;
let cwd = dunce::canonicalize(&root).unwrap_or(root);
let stderr: Arc<Mutex<Vec<String>>> = Arc::default();
let lines = stderr.clone();
let on_stderr: Box<dyn Fn(String) + Send + Sync> = Box::new(move |l| lines.lock().push(l));
let client = Dialect::wrap(Arc::new(ide_jsonrpc::NoopHandler));
let peer = (shared.connector)(&launch, &cwd, client.clone(), on_stderr)
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", launch.name, launch.command))?;
let conn = Conn::new(peer.clone(), client);
let work = async {
conn.initialize(&client_info()).await.map_err(|e| e.to_string())?;
let cwd_s = cwd.to_string_lossy().into_owned();
// No project MCP during discovery: Cursor can spend a long time spawning servers.
let new: Value = conn
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
.await
.map_err(|e| e.to_string())?;
let mut options = config::options_of(&new);
config::add_client_options(&launch.id, &mut options);
let model_option = options.iter().find(|o| o.category.as_deref() == Some("model"));
let effort = levels_for_model_from_slice(&options);
let levels: Vec<(String, Vec<AgentConfigOption>)> = match (model_option, effort) {
(Some(model), Some(effort)) if !model.values.is_empty() => {
model.values.iter().map(|v| (v.value.clone(), effort.clone())).collect()
}
(Some(model), Some(effort)) => model.current.clone().map(|m| vec![(m, effort)]).unwrap_or_default(),
_ => Vec::new(),
};
let option_ids: Vec<_> = options.iter().map(|o| o.id.as_str()).collect();
tracing::info!(
"agent {}: settings discovery got {} config option(s) {:?}, {} model effort list(s)",
launch.id,
options.len(),
option_ids,
levels.len()
);
shared.remember_discovered(&launch.id, &options, &[], &levels);
Ok::<(), String>(())
};
let result = tokio::time::timeout(DISCOVER_TIMEOUT, work).await;
peer.kill();
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(match stderr.lock().last() {
Some(line) => format!("{e}: {line}"),
None => e,
}),
Err(_) => {
let tail = stderr.lock().join("\n");
if tail.is_empty() {
Err("the agent did not answer in time".into())
} else {
Err(format!("the agent did not answer in time:\n{tail}"))
}
}
}
}
fn is_thought_level(o: &AgentConfigOption) -> bool {
o.category.as_deref() == Some("thought_level")
|| matches!(o.id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
}
fn levels_for_model_from_slice(options: &[AgentConfigOption]) -> Option<Vec<AgentConfigOption>> {
let levels: Vec<_> = options
.iter()
.filter(|o| is_thought_level(o))
.cloned()
.map(|mut o| {
if o.category.as_deref() != Some("thought_level") {
o.category = Some("thought_level".into());
}
o
})
.collect();
(!levels.is_empty()).then_some(levels)
}
+4
View File
@@ -14,6 +14,7 @@
//! - Agent → client ([`Dialect`]): `session/update`s are normalized ([`normalize`]) and
//! permission requests get the v1 `toolCall`.
pub(crate) mod discover;
mod normalize;
use ide_jsonrpc::{Handler, Peer, RpcError, codes};
@@ -57,6 +58,9 @@ fn client_capabilities() -> Value {
"terminal": false,
// Subagents as sessions of their own (ACP subagents RFD; v2 baseline).
"subagents": {},
// Cursor exposes model parameters (e.g. Composer `fast`) as separate config options
// only when the client advertises this flag (undocumented Cursor ACP extension).
"_meta": { "parameterizedModelPicker": true },
})
}
+74 -2
View File
@@ -93,9 +93,81 @@ fn the_negotiating_request_carries_both_versions() {
assert!(p["capabilities"].is_object());
assert_eq!(p["clientCapabilities"]["subagents"], json!({}));
assert_eq!(p["clientCapabilities"]["terminal"], false);
assert_eq!(
p["clientCapabilities"]["_meta"]["parameterizedModelPicker"], true,
"Cursor and similar agents need parameterized model options (e.g. fast on/off)"
);
// What we sent in the capture is what claude-agent-acp negotiated v2 and v1 from.
let sent = &frames(TURN, "out")[0]["params"];
assert_eq!(sent["clientCapabilities"], p["clientCapabilities"]);
let sent = frames(TURN, "out")[0]["params"]["clientCapabilities"].clone();
let mut expected = sent;
expected["_meta"] = p["clientCapabilities"]["_meta"].clone();
assert_eq!(expected, p["clientCapabilities"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_reads_effort_and_fast_from_session_new_then_stops_the_agent() {
#[derive(Default)]
struct DiscoveryAgent {
peer: std::sync::OnceLock<Peer>,
calls: parking_lot::Mutex<Vec<(String, Value)>>,
}
impl Handler for DiscoveryAgent {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
self.calls.lock().push((method.to_owned(), params));
match method {
"initialize" => Ok(json!({
"protocolVersion": 1,
"agentCapabilities": { "loadSession": true }
})),
"session/new" => Ok(json!({
"sessionId": "discover",
"configOptions": [
{"id": "model", "category": "model", "type": "select", "currentValue": "m1",
"options": [{"value": "m1", "name": "One"}, {"value": "m2", "name": "Two"}]},
{"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
})),
_ => Ok(json!({})),
}
}
}
let agent = Arc::new(DiscoveryAgent::default());
let tmp = tempfile::tempdir().unwrap();
let agent_for_connector = agent.clone();
let connector: crate::Connector = Arc::new(move |_, _, client, _| {
let (ca, cb) = tokio::io::duplex(1 << 20);
let (car, caw) = tokio::io::split(ca);
let (cbr, cbw) = tokio::io::split(cb);
let agent_peer = Peer::connect_framed(cbr, cbw, agent_for_connector.clone(), Framing::Lines);
let _ = agent_for_connector.peer.set(agent_peer);
Ok(Peer::connect_framed(car, caw, client, Framing::Lines))
});
let mgr = crate::AgentManager::with_connector(
crate::AgentPaths {
project_root: tmp.path().to_path_buf(),
store_dir: tmp.path().join("store"),
worktrees_dir: tmp.path().join("worktrees"),
models_file: tmp.path().join("models.json"),
custom_agents_dir: None,
home_dir: None,
},
tokio::runtime::Handle::current(),
connector,
);
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert_eq!(calls.iter().map(|(m, _)| m.as_str()).collect::<Vec<_>>(), ["initialize", "session/new"]);
assert_eq!(calls[1].1["mcpServers"], json!([]));
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
let fast = cursor.config.iter().find(|o| o.id == "fast").expect("fast option");
assert_eq!(fast.category.as_deref(), Some("model_config"));
assert_eq!(fast.current.as_deref(), Some("false"));
assert!(cursor.thought_levels.contains_key("m1") && cursor.thought_levels.contains_key("m2"));
assert!(cursor.thought_levels["m1"].iter().any(|o| o.category.as_deref() == Some("thought_level")));
}
#[test]
+24 -1
View File
@@ -107,7 +107,15 @@ pub(crate) fn parse_options(options: &Value) -> Vec<AgentConfigOption> {
Some(AgentConfigOption {
name: str_of(o, "name").unwrap_or_else(|| id.clone()),
description: str_of(o, "description"),
category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())),
category: str_of(o, "category").or_else(|| {
matches!(id.as_str(), "model" | "mode")
.then(|| id.clone())
.or_else(|| {
matches!(id.as_str(), "effort" | "reasoning_effort" | "thinking" | "reasoning")
.then(|| "thought_level".into())
})
.or_else(|| matches!(id.as_str(), "fast").then(|| "model_config".into()))
}),
current: str_of(o, "currentValue"),
values,
via: AgentConfigVia::Option,
@@ -333,6 +341,21 @@ mod tests {
assert!(bypass_approvals(&url_mode));
}
#[test]
fn effort_and_fast_categories_are_inferred_from_ids() {
let c = options_of(&json!({
"configOptions": [
{"id": "effort", "type": "select", "currentValue": "high",
"options": [{"value": "low"}, {"value": "high"}]},
{"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}
]
}));
assert_eq!(c.len(), 2);
assert_eq!(c[0].category.as_deref(), Some("thought_level"));
assert_eq!(c[1].category.as_deref(), Some("model_config"));
}
#[test]
fn command_sources_in_updates_and_stored_tasks() {
let update = json!({"availableCommands": [
+176 -15
View File
@@ -16,6 +16,7 @@ mod model;
mod native;
mod provider;
pub mod registry;
pub mod security;
mod skills;
mod timeline;
mod worktree;
@@ -39,6 +40,9 @@ use std::time::Duration;
/// Receives the events of one project's tasks.
pub type AgentSink = Arc<dyn Fn(AgentEvent) + Send + Sync>;
/// Returns the active command execution security policy for the project.
pub type SecurityHook = Arc<dyn Fn() -> ide_core::settings::AgentSecuritySettings + Send + Sync>;
/// Entry changes are batched for this long before they are sent.
const FLUSH_DELAY: Duration = Duration::from_millis(40);
/// Updates that arrive this late after `session/load` returns are still replayed history.
@@ -102,6 +106,7 @@ struct Shared {
runtime: tokio::runtime::Handle,
connector: Connector,
sink: RwLock<Option<AgentSink>>,
security: RwLock<Option<SecurityHook>>,
known: Mutex<HashMap<String, Known>>,
providers: RwLock<Vec<provider::ProviderSpec>>,
/// Last probe of each provider.
@@ -177,9 +182,15 @@ impl Shared {
let shared = self.clone();
// Keep the lookup alive if a composer unmounts while it is waiting.
self.runtime.spawn(async move {
if let Ok(found) =
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
{
tracing::info!("agent {}: settings discovery started", spec.id);
let discovered = if spec.driver == provider::Driver::Acp {
acp::discover::run(&shared, &spec).await.map(|_| None)
} else if spec.driver == provider::Driver::Claude {
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await.map(Some)
} else {
return;
};
if let Ok(Some(found)) = discovered {
let commands =
config::commands_of(&json!({ "availableCommands": found.commands }), &spec.id);
let options = config::options_of(&json!({ "configOptions": found.options }));
@@ -198,6 +209,8 @@ impl Shared {
shared.remember_discovered(&spec.id, &options, &commands, &levels);
})
.await;
} else if let Err(e) = discovered {
tracing::info!("agent {}: settings discovery failed: {e}", spec.id);
}
let _ = done.send(true);
});
@@ -220,12 +233,17 @@ impl Shared {
fn knows_all_levels(&self, provider: &str) -> bool {
let cache = self.known.lock();
let Some(known) = cache.get(provider) else { return false };
known
let models: Vec<&str> = known
.config
.iter()
.filter(|o| o.category.as_deref() == Some("model"))
.flat_map(|o| &o.values)
.all(|v| known.thought_levels.contains_key(&v.value))
.flat_map(|o| o.values.iter().map(|v| v.value.as_str()))
.collect();
// No advertised models yet: nothing to attach effort levels to.
if models.is_empty() {
return false;
}
models.iter().all(|id| known.thought_levels.contains_key(*id))
}
fn root_key(&self) -> String {
@@ -434,6 +452,7 @@ impl AgentManager {
runtime,
connector,
sink: RwLock::new(None),
security: RwLock::new(None),
known: Mutex::new(known),
providers,
probes: Mutex::new(HashMap::new()),
@@ -477,6 +496,11 @@ impl AgentManager {
tasks
}
/// Sets the callback used to read the active command execution security policy.
pub fn set_security_hook(&self, hook: SecurityHook) {
*self.shared.security.write() = Some(hook);
}
/// The agents (custom ones re-read), whether each is installed, and what they offered last time.
pub fn providers(&self) -> Vec<AgentProvider> {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -501,7 +525,9 @@ impl AgentManager {
let new = specs.iter().find(|p| &p.id == id);
matches!((old, new), (Some(a), Some(b)) if a.command == b.command && a.args == b.args && a.env == b.env)
});
*prev = specs;
*prev = specs.clone();
drop(probes);
drop(prev);
list
}
@@ -522,6 +548,30 @@ impl AgentManager {
provider::set_builtin_env(&dir, id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(&self, input: CustomAgentInput) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::save_custom(&dir, &input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(&self, id: &str) -> IdeResult<()> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::remove_custom(&dir, id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(&self, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
return Err(IdeError::unavailable("there is no custom agents directory"));
};
provider::import_acp(&dir, text)
}
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
pub fn reload_providers(&self) {
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
@@ -552,10 +602,16 @@ impl AgentManager {
let mut offered = offered;
if task.is_none()
&& (offered.is_empty() || !self.shared.has_config(&provider) || !self.shared.knows_all_levels(&provider))
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
&& let Some(spec) = self
.shared
.spec(&provider)
.filter(|s| matches!(s.driver, provider::Driver::Claude | provider::Driver::Acp))
{
let claude = spec.driver == provider::Driver::Claude;
self.shared.fetch_settings(spec).await;
offered = self.shared.known_commands(&provider);
if claude {
offered = self.shared.known_commands(&provider);
}
}
for &(name, description) in builtin_commands(&provider) {
if !offered.iter().any(|c| c.name == name) {
@@ -888,14 +944,38 @@ impl AgentManager {
}
o.clone()
};
let previous = target.current.clone();
// Cursor's `session/set_config_option` (Fast, effort) can take seconds. Show the
// choice first so the switch and slider move immediately.
apply_config_choice(&task, option, &value);
task.persist();
task.emit_task();
let live = task.live.lock().clone().filter(|l| !l.peer.is_closed());
let mut model_options =
target.category.as_deref() != Some("model") || target.current.as_deref() == Some(value.as_str());
target.category.as_deref() != Some("model") || previous.as_deref() == Some(value.as_str());
if let Some(live) = live.filter(|_| option != config::CURSOR_APPROVAL) {
let (method, mut params) = config::set_request(&target, &value);
params["sessionId"] = json!(live.session);
let reply: Value =
live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?;
let reply: Value = match live.peer.request(method, params, None).await {
Ok(reply) => reply,
Err(e) => {
{
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
if let Some(prev) = previous.clone() {
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: prev });
}
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = previous.clone();
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
task.persist();
task.emit_task();
return Err(IdeError::internal(task.describe(e)));
}
};
if let Some(options) = reply.get("configOptions") {
config::merge_update(&mut task.state.lock().task.config, options);
model_options = true;
@@ -906,8 +986,6 @@ impl AgentManager {
if !model_options {
s.task.config.retain(|o| o.category.as_deref() != Some("thought_level"));
}
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value);
}
@@ -1306,6 +1384,41 @@ impl AgentManager {
Ok(())
}
/// Accept: copies the task's changed files among `rel_paths` (all with `None`) from its
/// worktree into the local checkout, uncommitted, for the user to review and commit.
/// Files the checkout changed itself are left alone and reported. Only worktree tasks
/// have anything to accept.
pub async fn accept_changes(&self, id: &str, rel_paths: Option<Vec<String>>) -> IdeResult<AgentAcceptOutcome> {
let task = self.task(id)?;
if task.state.lock().busy {
return Err(IdeError::invalid("the agent is working: stop it before accepting its changes"));
}
let (isolation, cwd, base, root, before) = {
let s = task.state.lock();
(
s.task.isolation,
s.task.cwd.0.clone(),
s.task.base_commit.clone(),
s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()),
(*s.before).clone(),
)
};
if isolation != Isolation::Worktree {
return Err(IdeError::invalid("the task works in the local checkout: there is nothing to accept"));
}
let Some(base) = base else {
return Err(IdeError::invalid("the task has no base revision to accept from"));
};
blocking(move || {
let wanted: Vec<AgentChange> = worktree::without_untouched(worktree::changes(&cwd, &base)?, &before)
.into_iter()
.filter(|c| rel_paths.as_ref().is_none_or(|p| p.contains(&c.rel_path)))
.collect();
worktree::accept(&root, &cwd, &base, &wanted)
})
.await
}
/// Stops every agent (project closed).
pub fn shutdown(&self) {
for task in self.tasks.read().values() {
@@ -2124,6 +2237,44 @@ impl Task {
.iter()
.map(|question| question.answer_key.clone().unwrap_or_else(|| question.question.clone()))
.collect();
if let Some(hook) = self.shared.security.read().clone()
&& let Some(command) = security::extract_command(params)
{
match security::evaluate_command(&command, &hook()) {
security::PolicyDecision::Blocked(pattern) => {
let reject = options.iter().find(|o| o.kind.starts_with("reject")).cloned();
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("Blocked by policy ({pattern})")),
});
return match reject {
Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option.id } }),
None => json!({ "outcome": { "outcome": "cancelled" } }),
};
}
security::PolicyDecision::Allowed => {
if let Some(allow) = options
.iter()
.find(|o| o.kind == "allow_once")
.or_else(|| options.iter().find(|o| o.kind.starts_with("allow")))
.cloned()
{
let _ = self.push_entry(AgentEntry::Permission {
id,
title,
options,
questions: Vec::new(),
chosen: Some(format!("{} (whitelisted)", allow.name)),
});
return json!({ "outcome": { "outcome": "selected", "optionId": allow.id } });
}
}
security::PolicyDecision::AskUser => {}
}
}
let (tx, rx) = std::sync::mpsc::channel();
let entry =
self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), questions, chosen: None });
@@ -2437,7 +2588,7 @@ fn with_details(message: String, data: Option<&Value>) -> String {
}
}
fn client_info() -> Value {
pub(crate) fn client_info() -> Value {
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
}
@@ -2496,6 +2647,16 @@ fn retry_of(update: &Value, now: f64) -> Option<AgentRetry> {
})
}
fn apply_config_choice(task: &Task, option: &str, value: &str) {
let mut s = task.state.lock();
s.wanted.retain(|c| c.id != option);
s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.to_owned() });
if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) {
o.current = Some(value.to_owned());
}
s.task.model = model_of(&s.task.config).or(s.task.model.take());
}
fn model_of(config: &[AgentConfigOption]) -> Option<String> {
config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone())
}
+46
View File
@@ -602,3 +602,49 @@ pub struct McpServerInput {
fn default_true() -> bool {
true
}
/// Payload for creating a custom ACP agent (always the ACP driver); the id
/// becomes the `<config>/agents/<id>.toml` file name.
#[derive(Clone, Debug, Deserialize, Type)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CustomAgentInput {
pub id: String,
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
/// What importing one entry of a JetBrains `acp.json` file did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AcpImportOutcome {
/// The `agent_servers` key the entry was imported from.
pub name: String,
/// The id its agent file would get (also when skipped).
pub id: String,
pub saved: bool,
/// Why the entry was skipped; empty when it was saved.
#[serde(default)]
pub reason: String,
}
/// What accepting a worktree task's changes copied into the local checkout.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptOutcome {
/// Relative paths copied into the checkout (uncommitted), including ones already identical.
pub accepted: Vec<String>,
/// Files left alone because the checkout has its own edits to them.
pub skipped: Vec<AgentAcceptSkipped>,
}
/// A file accept left alone, with why.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AgentAcceptSkipped {
pub path: String,
pub reason: String,
}
+198 -1
View File
@@ -2,7 +2,7 @@
//! `<config>/agents/<id>.toml` (written by hand, or by installing one from the ACP
//! Registry, see `registry`).
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
use crate::{AcpImportOutcome, AgentCommandReadOnlyReason, AgentProvider, AgentSource, CustomAgentInput};
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -278,6 +278,127 @@ fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAg
Ok(())
}
/// Saves a new custom agent; refuses built-in and ACP Registry ids, taken ids
/// and an empty command. The id is also the agent file's name, so it must be
/// a safe file stem.
pub(crate) fn save_custom(custom_dir: &Path, input: &CustomAgentInput) -> IdeResult<()> {
let id = validated_custom_id(&input.id)?;
if builtin_spec(&id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent; change its command instead")));
}
if input.command.trim().is_empty() {
return Err(IdeError::invalid("the agent command must not be empty"));
}
let file = custom_dir.join(format!("{id}.toml"));
if file.try_exists()? {
if CustomAgent::read(&file).is_ok_and(|a| a.registry.is_some()) {
return Err(IdeError::invalid(format!(
"{id} is installed from the ACP Registry; uninstall it there to replace it"
)));
}
return Err(IdeError::invalid(format!("there is already a custom agent '{id}'")));
}
CustomAgent {
name: Some(input.name.clone()),
driver: Driver::Acp,
command: input.command.clone(),
args: input.args.clone(),
env: input.env.clone(),
registry: None,
}
.write(&file)?;
Ok(())
}
/// Removes a custom agent's file; refuses built-in, ACP Registry and missing ids.
pub(crate) fn remove_custom(custom_dir: &Path, id: &str) -> IdeResult<()> {
let id = validated_custom_id(id)?;
let id = id.as_str();
if builtin_spec(id).is_some() {
return Err(IdeError::invalid(format!("{id} is a built-in agent and cannot be removed")));
}
let file = custom_dir.join(format!("{id}.toml"));
let agent =
CustomAgent::read(&file).map_err(|_| IdeError::not_found(format!("there is no custom agent '{id}'")))?;
if agent.registry.is_some() {
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry; uninstall it there")));
}
match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom
/// agents, one outcome per entry; entries that collide, lack a command or
/// have no usable name are skipped with a reason.
pub(crate) fn import_acp(custom_dir: &Path, text: &str) -> IdeResult<Vec<AcpImportOutcome>> {
#[derive(Deserialize)]
struct AcpJson {
#[serde(default)]
agent_servers: BTreeMap<String, AcpServer>,
}
#[derive(Deserialize)]
struct AcpServer {
#[serde(default)]
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
let parsed: AcpJson =
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("not an acp.json file: {e}")))?;
let mut outcomes = Vec::with_capacity(parsed.agent_servers.len());
for (name, server) in parsed.agent_servers {
let id = slugify_id(&name);
let mut outcome = AcpImportOutcome { name, id: id.clone(), saved: false, reason: String::new() };
if id.is_empty() {
outcome.reason = "the name has no usable characters for an id".to_owned();
} else if builtin_spec(&id).is_some() {
outcome.reason = format!("{id} is a built-in agent");
} else if server.command.trim().is_empty() {
outcome.reason = "the entry has no command".to_owned();
} else if custom_dir.join(format!("{id}.toml")).try_exists()? {
outcome.reason = format!("there is already a custom agent '{id}'");
} else {
CustomAgent {
name: Some(outcome.name.clone()),
driver: Driver::Acp,
command: server.command,
args: server.args,
env: server.env,
registry: None,
}
.write(&custom_dir.join(format!("{id}.toml")))?;
outcome.saved = true;
}
outcomes.push(outcome);
}
Ok(outcomes)
}
/// A custom agent id doubles as a file stem: letters, digits, `-` and `_` only.
fn validated_custom_id(id: &str) -> IdeResult<String> {
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return Ok(id.to_owned());
}
Err(IdeError::invalid(format!("'{id}' is not a usable agent id (letters, digits, '-' and '_' only)")))
}
/// Turns an `acp.json` server name into a file-stem id (`"My Agent!"` → `"my-agent"`).
fn slugify_id(name: &str) -> String {
let mut id = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
id.push(c.to_ascii_lowercase());
} else if !id.ends_with('-') {
id.push('-');
}
}
id.trim_matches('-').to_owned()
}
/// An agent file, `<config>/agents/<id>.toml`.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -753,4 +874,80 @@ mod tests {
assert_eq!(spec.source, AgentSource::Registry { version: "0.10.0".into(), update: None });
assert!(toml::from_str::<CustomAgent>("command = \"x\"\n[registry]\nid = \"x\"\n").is_err());
}
fn custom_input(id: &str) -> CustomAgentInput {
CustomAgentInput {
id: id.into(),
name: "Mine".into(),
command: "/opt/mine".into(),
args: vec!["acp".into()],
env: BTreeMap::from([("K".to_owned(), "v".to_owned())]),
}
}
#[test]
fn custom_agents_save_list_and_remove() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap();
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "mine").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine"));
assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]);
assert_eq!(mine.source, AgentSource::Custom);
remove_custom(dir.path(), "mine").unwrap();
assert!(!load(Some(dir.path())).iter().any(|s| s.id == "mine"));
remove_custom(dir.path(), "mine").unwrap_err();
}
#[test]
fn custom_agents_refuse_builtin_registry_and_bad_ids() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("claude")).unwrap_err();
save_custom(dir.path(), &custom_input("../evil")).unwrap_err();
save_custom(dir.path(), &custom_input("")).unwrap_err();
let mut empty_command = custom_input("mine");
empty_command.command = " ".into();
save_custom(dir.path(), &empty_command).unwrap_err();
save_custom(dir.path(), &custom_input("mine")).unwrap();
save_custom(dir.path(), &custom_input("mine")).unwrap_err();
remove_custom(dir.path(), "claude").unwrap_err();
// An id that isn't a plain file stem never reaches outside the agents directory.
let outside = dir.path().join("outside.toml");
std::fs::write(&outside, "command = \"/bin/sh\"\n").unwrap();
let agents = dir.path().join("agents");
std::fs::create_dir(&agents).unwrap();
remove_custom(&agents, "../outside").unwrap_err();
assert!(outside.exists());
std::fs::write(
dir.path().join("amp-acp.toml"),
"command = \"/registry/amp\"\n[registry]\nid = \"amp-acp\"\nversion = \"1.0\"\n",
)
.unwrap();
save_custom(dir.path(), &custom_input("amp-acp")).unwrap_err();
remove_custom(dir.path(), "amp-acp").unwrap_err();
}
#[test]
fn acp_json_imports_servers_and_skips_collisions() {
let dir = tempfile::tempdir().unwrap();
save_custom(dir.path(), &custom_input("taken")).unwrap();
let outcomes = import_acp(
dir.path(),
r#"{"agent_servers": {
"My Agent!": {"command": "/opt/agent", "args": ["--acp"], "env": {"K": "v"}},
"taken": {"command": "/opt/taken"},
"claude": {"command": "/opt/claude"},
"!!!": {"command": "/opt/noname"},
"silent": {"args": []}
}}"#,
)
.unwrap();
let saved: Vec<&str> = outcomes.iter().filter(|o| o.saved).map(|o| o.id.as_str()).collect();
assert_eq!(saved, ["my-agent"]);
assert!(outcomes.iter().filter(|o| !o.saved).all(|o| !o.reason.is_empty()));
let mine = load(Some(dir.path())).into_iter().find(|s| s.id == "my-agent").unwrap();
assert_eq!((mine.name.as_str(), mine.command.as_str()), ("My Agent!", "/opt/agent"));
assert_eq!(mine.source, AgentSource::Custom);
import_acp(dir.path(), "not json").unwrap_err();
assert!(import_acp(dir.path(), "{}").unwrap().is_empty());
}
}
+487
View File
@@ -0,0 +1,487 @@
//! Command execution security policy (Whitelist / Blacklist) for coding agents.
use ide_core::settings::AgentSecuritySettings;
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicyDecision {
/// Command matched a blacklist pattern (`pattern`).
Blocked(String),
/// Command matched the whitelist and no blacklist pattern.
Allowed,
/// Neither matched; prompt the user as usual.
AskUser,
}
/// Extracts the shell command string from an ACP `session/request_permission` payload
/// when the request is for executing a command.
pub fn extract_command(params: &Value) -> Option<String> {
let tool = params.get("toolCall").or_else(|| params.pointer("/subject/toolCall"))?;
let raw_cmd = tool.pointer("/rawInput/command").or_else(|| tool.pointer("/rawInput/cmd"));
if let Some(cmd) = raw_cmd.and_then(command_from_value) {
return Some(cmd);
}
let kind = tool.get("kind").and_then(Value::as_str).unwrap_or_default();
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let title =
tool.get("title").and_then(Value::as_str).or_else(|| params.get("title").and_then(Value::as_str))?.trim();
if let Some(cmd) = title.strip_prefix("Run ").map(str::trim).filter(|s| !s.is_empty()) {
return Some(cmd.to_owned());
}
if (kind == "execute" || matches!(name, "Bash" | "PowerShell" | "Execute" | "Run" | "terminal"))
&& !title.is_empty()
&& title != "Terminal"
&& title != "Run a command"
{
return Some(title.to_owned());
}
None
}
fn command_from_value(val: &Value) -> Option<String> {
match val {
Value::String(s) => {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
Value::Array(items) => {
let words: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
if words.is_empty() {
return None;
}
let cmd = match words.as_slice() {
[_, flag, script] if flag.starts_with('-') && flag.ends_with('c') => (*script).trim().to_owned(),
_ => words.join(" ").trim().to_owned(),
};
(!cmd.is_empty()).then_some(cmd)
}
_ => None,
}
}
/// Checks whether a single pattern matches `command` using prefix + glob rules:
/// - If `pattern` contains `*` or `?`, matches using glob wildcards (where `*` matches
/// any sequence of characters). A trailing ` *` in the pattern also matches the bare
/// command without arguments (e.g. `git status *` matches `git status`).
/// - Otherwise, matches when `command` equals `pattern` or starts with `pattern` followed
/// by whitespace (prefix match at word boundary).
pub fn matches_command(pattern: &str, command: &str) -> bool {
let pat = pattern.trim();
let cmd = command.trim();
if pat.is_empty() || cmd.is_empty() {
return false;
}
if pat.contains('*') || pat.contains('?') {
if let Some(stem) = pat.strip_suffix(" *")
&& cmd == stem.trim()
{
return true;
}
wildcard_match(pat.as_bytes(), cmd.as_bytes())
} else {
cmd == pat || cmd.strip_prefix(pat).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
}
fn wildcard_match(pat: &[u8], text: &[u8]) -> bool {
let (mut p, mut t) = (0, 0);
let (mut star_p, mut star_t) = (None, 0);
while t < text.len() {
if p < pat.len() && (pat[p] == b'?' || pat[p] == text[t]) {
p += 1;
t += 1;
} else if p < pat.len() && pat[p] == b'*' {
star_p = Some(p);
p += 1;
star_t = t;
} else if let Some(sp) = star_p {
p = sp + 1;
star_t += 1;
t = star_t;
} else {
return false;
}
}
while p < pat.len() && pat[p] == b'*' {
p += 1;
}
p == pat.len()
}
/// Matches an allowed command entry against a sub-command.
/// An allow entry is a whole command or a prefix only up to a word boundary:
/// e.g. `git status` matches `git status` and `git status -s`, but NOT `git statusx`.
/// Any accidental trailing `*` in the allow entry is stripped so `git status*` behaves
/// cleanly as a prefix at word boundaries instead of a glob wildcard.
pub fn matches_allow_entry(entry: &str, command: &str) -> bool {
let mut entry = entry.trim();
if let Some(stripped) = entry.strip_suffix('*') {
entry = stripped.trim_end();
}
let cmd = command.trim();
if entry.is_empty() || cmd.is_empty() {
return false;
}
cmd == entry || cmd.strip_prefix(entry).is_some_and(|rest| rest.starts_with(char::is_whitespace))
}
/// Checks whether a word is an environment variable assignment (`IDENTIFIER=...`).
fn is_env_assignment_word(word: &str) -> bool {
let Some((name, _)) = word.split_once('=') else {
return false;
};
if name.is_empty() {
return false;
}
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Returns true if the sub-command starts with an environment variable assignment,
/// e.g. `FOO=bar cmd` or `PORT=3000`.
fn has_leading_env_assignment(cmd: &str) -> bool {
let trimmed = cmd.trim_start();
let bytes = trimmed.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
}
i += 1;
continue;
}
if b == b'\'' || b == b'"' {
quote = Some(b);
i += 1;
continue;
}
if b.is_ascii_whitespace() {
break;
}
i += 1;
}
let first_word = &trimmed[..i];
is_env_assignment_word(first_word)
}
/// Checks whether `command` contains shell constructs that must never be auto-approved:
/// - Command substitution: `$(...)` or backticks
/// - Redirection: `>`, `>>`, `<`
/// - Here-docs: `<<`
/// - Subshells: `(...)`
/// - Leading environment assignments: `VAR=val cmd`
pub fn has_unsafe_shell_syntax(command: &str) -> bool {
let cmd = command.trim();
if cmd.is_empty() {
return false;
}
if has_leading_env_assignment(cmd) {
return true;
}
let bytes = cmd.as_bytes();
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && quote != Some(b'\'') && i + 1 < bytes.len() {
i += 2;
continue;
}
if let Some(q) = quote {
if b == q {
quote = None;
i += 1;
continue;
}
// Inside double quotes: $(...) and `...` are evaluated by the shell.
if q == b'"' {
if b == b'`' {
return true;
}
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
return true;
}
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'`' => return true,
b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'(' => return true,
b'<' | b'>' => return true,
b'(' | b')' => return true,
_ => i += 1,
}
}
false
}
/// Splits a shell command into sub-commands separated by `;`, `&&`, `||`, `|`, or `\n`
/// (outside single/double quotes) so compound commands cannot bypass policy checks.
fn split_subcommands(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = quote {
if b == b'\\' && q == b'"' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == q {
quote = None;
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => {
quote = Some(b);
i += 1;
}
b'\\' if i + 1 < bytes.len() => {
i += 2;
}
b';' | b'\n' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
i += 1;
start = i;
}
b'&' | b'|' => {
let seg = command[start..i].trim();
if !seg.is_empty() {
out.push(seg);
}
if i + 1 < bytes.len() && bytes[i + 1] == b {
i += 2;
} else {
i += 1;
}
start = i;
}
_ => i += 1,
}
}
let tail = command[start..].trim();
if !tail.is_empty() {
out.push(tail);
}
out
}
/// Evaluates `command` against `settings`:
/// 1. Blacklist takes precedence: if the full command or any sub-command matches a
/// `blocked_commands` entry, returns [`PolicyDecision::Blocked`].
/// 2. Whitelist: allowed_commands cannot auto-approve when empty.
/// 3. Every sub-command in a chain must be safe (no command substitution, redirection,
/// subshell, or env assignments) and match at least one `allowed_commands` entry
/// exactly or up to word boundary; otherwise falls back to [`PolicyDecision::AskUser`].
pub fn evaluate_command(command: &str, settings: &AgentSecuritySettings) -> PolicyDecision {
let cmd = command.trim();
if cmd.is_empty() {
return PolicyDecision::AskUser;
}
let subcommands = split_subcommands(cmd);
for pat in &settings.blocked_commands {
let pat = pat.trim();
if pat.is_empty() {
continue;
}
if matches_command(pat, cmd) || subcommands.iter().any(|sub| matches_command(pat, sub)) {
return PolicyDecision::Blocked(pat.to_owned());
}
}
if settings.allowed_commands.is_empty() || subcommands.is_empty() {
return PolicyDecision::AskUser;
}
let all_allowed = subcommands.iter().all(|sub| {
let s = sub.trim();
if s.is_empty() || has_unsafe_shell_syntax(s) {
return false;
}
settings.allowed_commands.iter().any(|entry| matches_allow_entry(entry, s))
});
if all_allowed {
return PolicyDecision::Allowed;
}
PolicyDecision::AskUser
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prefix_and_glob_matching() {
assert!(matches_command("git status", "git status"));
assert!(matches_command("git status", "git status -s"));
assert!(!matches_command("git status", "git statusbar"));
assert!(matches_command("git status*", "git status"));
assert!(matches_command("git status*", "git status --short"));
assert!(matches_command("npm test *", "npm test"));
assert!(matches_command("npm test *", "npm test unit"));
assert!(matches_command("rm -rf *", "rm -rf /tmp/foo"));
assert!(matches_command("rm -rf *", "rm -rf /"));
}
#[test]
fn blacklist_takes_precedence_and_catches_chained_commands() {
let settings = AgentSecuritySettings::default();
assert_eq!(evaluate_command("rm -rf /tmp/build", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command(":(){ :|:& };:", &settings), PolicyDecision::Blocked(":(){ :|:& };:".into()));
}
#[test]
fn allow_entry_matching_word_boundary() {
assert!(matches_allow_entry("git status", "git status"));
assert!(matches_allow_entry("git status", "git status -s"));
assert!(!matches_allow_entry("git status", "git statusbar"));
assert!(!matches_allow_entry("git status", "git statusx"));
// Trailing asterisk in allow entry normalizes cleanly to word boundary prefix
assert!(matches_allow_entry("git status*", "git status"));
assert!(matches_allow_entry("git status*", "git status --short"));
assert!(!matches_allow_entry("git status*", "git statusbar"));
assert!(matches_allow_entry("ls", "ls"));
assert!(matches_allow_entry("ls", "ls -la"));
assert!(!matches_allow_entry("ls", "lsof"));
assert!(matches_allow_entry("cargo check", "cargo check"));
assert!(matches_allow_entry("cargo check", "cargo check --workspace"));
assert!(!matches_allow_entry("cargo check", "cargo checkmate"));
}
#[test]
fn unsafe_shell_syntax_detection() {
// Command substitutions: $(...) and `...`
assert!(has_unsafe_shell_syntax("echo $(whoami)"));
assert!(has_unsafe_shell_syntax("echo \"user: $(whoami)\""));
assert!(has_unsafe_shell_syntax("echo `whoami`"));
assert!(has_unsafe_shell_syntax("echo \"`whoami`\""));
// Redirections: >, >>, <
assert!(has_unsafe_shell_syntax("git status > /tmp/out"));
assert!(has_unsafe_shell_syntax("git status >> /tmp/out"));
assert!(has_unsafe_shell_syntax("cat < /tmp/in"));
assert!(has_unsafe_shell_syntax("cat << 'EOF'"));
// Subshells: (...)
assert!(has_unsafe_shell_syntax("(cd /tmp && ls)"));
assert!(has_unsafe_shell_syntax("git status && (exit 1)"));
// Environment assignments: VAR=val ...
assert!(has_unsafe_shell_syntax("FOO=bar git status"));
assert!(has_unsafe_shell_syntax("PORT=3000 npm test"));
assert!(has_unsafe_shell_syntax("_SECRET=123 cargo test"));
assert!(has_unsafe_shell_syntax("A=\"b c\" git status"));
// Safe constructs with characters inside quotes or command flags
assert!(!has_unsafe_shell_syntax("git commit -m \"fix <bug> and (issue)\""));
assert!(!has_unsafe_shell_syntax("git commit -m 'fix <bug>'"));
assert!(!has_unsafe_shell_syntax("cargo test --test=unit"));
assert!(!has_unsafe_shell_syntax("git status -s"));
}
#[test]
fn default_settings_has_no_auto_approval_and_blocks_dangerous_defaults() {
let settings = AgentSecuritySettings::default();
assert!(settings.allowed_commands.is_empty(), "allowed_commands must start empty");
// Safe commands ask user by default
assert_eq!(evaluate_command("git status", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("ls", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo check", &settings), PolicyDecision::AskUser);
// Blacklist patterns are still blocked by default
assert_eq!(evaluate_command("rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
assert_eq!(evaluate_command("rm -rf /tmp/test", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn whitelist_allows_safe_single_and_chained_commands_only() {
let settings = AgentSecuritySettings {
allowed_commands: vec!["git status".into(), "git diff".into(), "cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
};
assert_eq!(evaluate_command("git status -s", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git diff HEAD~1", &settings), PolicyDecision::Allowed);
assert_eq!(evaluate_command("git status && cargo test", &settings), PolicyDecision::Allowed);
// Word boundary: git statusx must not match git status
assert_eq!(evaluate_command("git statusx", &settings), PolicyDecision::AskUser);
// Unsafe syntax (redirection, command substitution, env assignment) falls back to AskUser
assert_eq!(evaluate_command("git status > out.txt", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("git status $(whoami)", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("FOO=bar git status", &settings), PolicyDecision::AskUser);
// Chaining an unapproved command must still ask the user.
assert_eq!(evaluate_command("git status && curl https://example.com", &settings), PolicyDecision::AskUser);
assert_eq!(evaluate_command("cargo publish", &settings), PolicyDecision::AskUser);
// Blacklist still takes precedence over whitelist
assert_eq!(evaluate_command("git status && rm -rf /", &settings), PolicyDecision::Blocked("rm -rf *".into()));
}
#[test]
fn extracts_command_from_v1_v2_and_codex_payloads() {
let claude = json!({
"toolCall": {
"title": "git status",
"kind": "execute",
"rawInput": { "command": "git status --short" }
}
});
assert_eq!(extract_command(&claude).as_deref(), Some("git status --short"));
let codex = json!({
"toolCall": {
"title": "Run cargo test",
"kind": "execute",
"rawInput": { "command": ["/bin/zsh", "-lc", "cargo test --workspace"] }
}
});
assert_eq!(extract_command(&codex).as_deref(), Some("cargo test --workspace"));
let write = json!({
"toolCall": {
"title": "Write foo.txt",
"kind": "edit",
"rawInput": { "file_path": "foo.txt" }
}
});
assert_eq!(extract_command(&write), None);
}
}
+166
View File
@@ -43,6 +43,11 @@ struct FakeAgent {
/// Hold the late mode reply until the prompt's permission request is already waiting.
hold_late_mode: bool,
empty_config: AtomicBool,
/// Blocks `session/new` until discovery times out (see `acp::discover` test timeout).
hold_session_new: AtomicBool,
/// Sleeps this long at the start of `session/set_config_option`, after noting the time.
delay_config: Duration,
config_entered: Mutex<Option<std::time::Instant>>,
/// Config options `session/new` reports after the model one (per-model efforts, say).
extra_options: Mutex<Vec<Value>>,
model_options: Mutex<HashMap<String, Vec<Value>>>,
@@ -157,6 +162,9 @@ impl Handler for FakeAgent {
)
}
"session/new" => {
if self.hold_session_new.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_secs(5));
}
if self.empty_config.load(Ordering::Acquire) {
return Ok(json!({"sessionId": "s1"}));
}
@@ -190,6 +198,10 @@ impl Handler for FakeAgent {
}
}
"session/set_config_option" | "session/set_mode" => {
if method == "session/set_config_option" && !self.delay_config.is_zero() {
*self.config_entered.lock() = Some(std::time::Instant::now());
std::thread::sleep(self.delay_config);
}
self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap()));
if params["configId"] == "model" && !self.model_options.lock().is_empty() {
return Ok(json!({"configOptions": self.options_for(params["value"].as_str().unwrap())}));
@@ -2631,6 +2643,92 @@ async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn provider_reload_does_not_start_acp_discovery() {
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.reload_providers();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(agent.calls.lock().is_empty(), "reload must not spawn cursor-agent");
}
#[tokio::test(flavor = "multi_thread")]
async fn composer_commands_discover_cursor_effort_and_fast() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![
json!({"id": "effort", "type": "select", "currentValue": "medium",
"options": [{"value": "low"}, {"value": "medium"}, {"value": "high"}]}),
json!({"id": "fast", "type": "select", "currentValue": "false",
"options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "On"}]}),
]),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
mgr.commands("cursor", None).await.unwrap();
let calls = agent.calls.lock();
assert!(calls.iter().any(|m| m.as_str() == "session/new"));
assert_eq!(calls.iter().filter(|m| m.as_str() == "initialize").count(), 1);
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert_eq!(cursor.config.iter().find(|o| o.id == "fast").unwrap().current.as_deref(), Some("false"));
assert!(
cursor
.thought_levels
.values()
.next()
.is_some_and(|levels| { levels.iter().any(|o| o.category.as_deref() == Some("thought_level")) })
);
}
#[tokio::test(flavor = "multi_thread")]
async fn config_choice_shows_before_the_agent_answers() {
let agent = Arc::new(FakeAgent {
extra_options: Mutex::new(vec![json!({
"id": "fast", "category": "model_config", "type": "select", "currentValue": "true",
"options": [{"value": "true", "name": "On"}, {"value": "false", "name": "Off"}]
})]),
delay_config: Duration::from_millis(300),
..Default::default()
});
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let shown: Arc<Mutex<Option<std::time::Instant>>> = Arc::default();
let mark = shown.clone();
mgr.subscribe(Arc::new(move |e| {
if let AgentEvent::Task { task } = e
&& task.config.iter().any(|o| o.id == "fast" && o.current.as_deref() == Some("false"))
&& mark.lock().is_none()
{
*mark.lock() = Some(std::time::Instant::now());
}
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("again"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
mgr.set_config(&task.id, "fast", "false".into()).await.unwrap();
let shown = (*shown.lock()).expect("the switch moved");
let entered = (*agent.config_entered.lock()).expect("the agent received the change");
assert!(shown < entered, "the composer updates before the agent answers");
}
#[tokio::test(flavor = "multi_thread")]
async fn discovery_times_out_when_session_new_hangs() {
let agent = Arc::new(FakeAgent { hold_session_new: AtomicBool::new(true), ..Default::default() });
let mgr = manager(tempfile::tempdir().unwrap().path(), agent.clone());
let started = std::time::Instant::now();
mgr.commands("cursor", None).await.unwrap();
assert!(started.elapsed() < Duration::from_secs(10), "discovery should time out quickly in tests");
let cursor = mgr.providers().into_iter().find(|p| p.id == "cursor").unwrap();
assert!(!cursor.config.iter().any(|o| o.category.as_deref() == Some("model")));
}
#[tokio::test(flavor = "multi_thread")]
async fn discovered_effort_levels_fill_models_no_session_has_reported() {
let tmp = tempfile::tempdir().unwrap();
@@ -2770,3 +2868,71 @@ async fn rolling_back_restores_the_start_of_the_task_and_keeps_the_users_own_edi
mgr.delete(&task.id, false).await.unwrap();
assert!(!dir.join("store").join(format!("{}.before", task.id)).exists());
}
#[tokio::test(flavor = "multi_thread")]
async fn security_policy_auto_approves_whitelist_and_rejects_blacklist() {
use ide_core::settings::AgentSecuritySettings;
// 1. Whitelist auto-approves "cargo test" without waiting for manual respond().
let tmp = tempfile::tempdir().unwrap();
let agent = Arc::new(FakeAgent::default());
let mgr = manager(tmp.path(), agent);
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec!["rm -rf *".into()],
}));
let task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("run tests"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
let entries = mgr.entries(&task.id, None).unwrap();
assert!(
entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose yes"))),
"{entries:?}"
);
assert!(
entries.iter().any(|e| matches!(
e,
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("whitelisted")
)),
"{entries:?}"
);
// 2. Blacklist auto-rejects "cargo test" when matched by blocked_commands.
mgr.set_security_hook(Arc::new(|| AgentSecuritySettings {
allowed_commands: vec!["cargo test".into()],
blocked_commands: vec!["cargo test*".into()],
}));
let blocked_task = mgr
.create(NewAgentTask {
project_root: None,
prompt: text("run blocked"),
provider: "claude".into(),
config: vec![],
isolation: Isolation::Local,
base: None,
})
.await
.unwrap();
wait_for(&mgr, &blocked_task.id, AgentStatus::Ready).await;
let blocked_entries = mgr.entries(&blocked_task.id, None).unwrap();
assert!(
blocked_entries.iter().any(|e| matches!(e, AgentEntry::Agent { text } if text.contains("chose no"))),
"{blocked_entries:?}"
);
assert!(
blocked_entries.iter().any(|e| matches!(
e,
AgentEntry::Permission { chosen: Some(c), .. } if c.contains("Blocked by policy")
)),
"{blocked_entries:?}"
);
}
+101
View File
@@ -125,6 +125,50 @@ 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)>;
@@ -406,4 +450,61 @@ 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");
}
}
+31 -1
View File
@@ -266,6 +266,11 @@ pub(super) fn maven_steps(
BuildKind::Rebuild => &["clean", "test-compile"],
BuildKind::Artifacts => &["package", "-DskipTests"],
};
// The IDE's compile builds are not coverage runs. JaCoCo's offline `instrument`
// rewrites target/classes in `process-classes`; `jacoco.skip` prevents that in this
// build. It does not restore classes instrumented by an earlier build: Rebuild's
// `clean` removes those, while an incremental Build can still reuse them.
let skip_jacoco = matches!(kind, BuildKind::Build | BuildKind::Rebuild);
let tops: Vec<&maven::MavenProject> = m.projects.iter().filter(|p| !p.aggregated).collect();
if tops.is_empty() {
return Err(IdeError::not_found("no Maven project found"));
@@ -278,6 +283,9 @@ pub(super) fn maven_steps(
Format::Maven,
);
s.args.extend(p.options.iter().cloned());
if skip_jacoco {
s.args.push("-Djacoco.skip=true".into());
}
s.args.extend(extra.iter().cloned());
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
s.env = p.env.clone();
@@ -1041,6 +1049,22 @@ mod tests {
std::fs::write(p, text).unwrap();
}
#[test]
fn maven_compile_steps_skip_jacoco_but_artifacts_do_not() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("demo");
write(&root, "pom.xml", "<project><artifactId>demo</artifactId></project>");
let model = crate::load(&root, &tmp.path().join("cache"), None);
for (kind, expected) in [(BuildKind::Build, true), (BuildKind::Rebuild, true), (BuildKind::Artifacts, false)] {
let plan = plan(&root, &model, kind, None, None).unwrap();
assert_eq!(
plan.steps[0].args.iter().any(|arg| arg == "-Djacoco.skip=true"),
expected,
"{kind:?}: {plan:?}"
);
}
}
#[test]
fn plans_per_build_system() {
let tmp = tempfile::tempdir().unwrap();
@@ -1057,7 +1081,10 @@ mod tests {
assert_eq!(p.system, BuildSystem::Maven);
assert_eq!(p.title, "demo");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
assert_eq!(
p.steps[0].args,
["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "-Djacoco.skip=true", "test-compile"]
);
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
assert_eq!(
p.steps[0].args,
@@ -1065,6 +1092,7 @@ mod tests {
"-B",
"-Dstyle.color=never",
maven::LOCAL_REPO_TRACKING_ARG,
"-Djacoco.skip=true",
"-pl",
"core",
"-am",
@@ -1353,6 +1381,7 @@ mod tests {
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
@@ -1369,6 +1398,7 @@ mod tests {
maven::LOCAL_REPO_TRACKING_ARG,
"-s",
"settings.xml",
"-Djacoco.skip=true",
"-pl",
"app",
"-am",
+1
View File
@@ -536,6 +536,7 @@ mod tests {
crate::maven::LOCAL_REPO_TRACKING_ARG,
"-o",
"-Peditor",
"-Djacoco.skip=true",
"test-compile"
]
);
+13
View File
@@ -275,6 +275,11 @@ pub enum DebugEvent {
},
/// The session's capabilities changed after it started: fetch them again.
Capabilities,
/// The adapter's `invalidated` event: what it shows for `areas` (`all`, `stacks`, `threads`,
/// `variables`; empty means all) is stale and has to be fetched again.
Invalidated {
areas: Vec<String>,
},
JvmBackend {
state: JvmBackendState,
},
@@ -330,4 +335,12 @@ mod tests {
);
assert_eq!(serde_json::to_value(DebugEvent::Capabilities).unwrap(), json!({"kind": "capabilities"}));
}
#[test]
fn invalidated_event_json_shape() {
assert_eq!(
serde_json::to_value(DebugEvent::Invalidated { areas: vec!["variables".into()] }).unwrap(),
json!({"kind": "invalidated", "areas": ["variables"]})
);
}
}
+3
View File
@@ -63,6 +63,8 @@ pub struct Settings {
pub agents: AgentSettings,
/// Agents › MCP Server (global only).
pub mcp_server: McpServerSettings,
/// Tools › Agents: Command Execution Policy.
pub agent_security: AgentSecuritySettings,
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
@@ -411,6 +413,7 @@ impl Default for Settings {
cmake: CMakeSettings::default(),
agents: AgentSettings::default(),
mcp_server: McpServerSettings::default(),
agent_security: AgentSecuritySettings::default(),
plugins: BTreeMap::new(),
}
}
+27
View File
@@ -412,3 +412,30 @@ impl Default for ExternalTool {
}
}
}
/// Tools › Agents: Command Execution Policy (whitelist and blacklist patterns).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct AgentSecuritySettings {
/// Commands or patterns (`git status*`, `pwd`) automatically approved without prompting.
pub allowed_commands: Vec<String>,
/// Commands or patterns (`rm -rf *`, `mkfs*`) automatically rejected.
pub blocked_commands: Vec<String>,
}
impl Default for AgentSecuritySettings {
fn default() -> Self {
Self {
allowed_commands: Vec::new(),
blocked_commands: vec![
"rm -rf *".into(),
"rm -rf /".into(),
"mkfs*".into(),
"dd *".into(),
":(){ :|:& };:".into(),
"shutdown*".into(),
"reboot*".into(),
],
}
}
}
+7
View File
@@ -72,6 +72,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
),
// Inside the language server: reached through `debugServer`, never started.
"kotlin-lsp" => cfg(
"kotlin-lsp",
&[],
"tcp",
"Install JetBrains Kotlin LSP in Settings › Languages & Frameworks › Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
if cfg!(windows) { "python" } else { "python3" },
+1 -1
View File
@@ -29,7 +29,7 @@ pub(crate) use scan::watch_for_attached_jvm;
pub(crate) use scan::{JvmFinder, attach_target, find_attached_jvm, find_local_jvm, local_jdwp_port};
use snapshot::{AttachProbe, JdkProbe, take_snapshot};
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER];
pub const JVM_MEMORY_ADAPTERS: &[&str] = &["java-debug", crate::kotlin::ADAPTER, crate::kotlin_lsp::ADAPTER];
pub const HISTOGRAM_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
+9 -5
View File
@@ -9,11 +9,15 @@ use std::{
pub const ADAPTER: &str = "kotlin-debug-adapter";
/// The adapter attaches to a JVM the IDE starts, through the JDWP link. It resumes the VM while
/// constructing its debuggee, before its breakpoint hooks exist, so the link holds resumes back
/// until the attach is done.
pub(crate) fn link_options() -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: true }
/// fwcd's adapter or kotlin-lsp's: both debug a JVM the IDE starts, through the JDWP link.
pub fn is_kotlin(adapter: &str) -> bool {
adapter == ADAPTER || adapter == crate::kotlin_lsp::ADAPTER
}
/// fwcd's adapter resumes the VM while constructing its debuggee, before its breakpoint hooks
/// exist, so only its link holds resumes back until the attach is done.
pub(crate) fn link_options(adapter: &str) -> ide_jdwp::LinkOptions {
ide_jdwp::LinkOptions { hold_resumes: adapter == ADAPTER }
}
pub struct SourceMirror {
+147
View File
@@ -0,0 +1,147 @@
//! JetBrains kotlin-lsp's debugger: a DAP server inside the language server, reached through
//! the `debugServer` port it opens once the project is imported.
//!
//! Known limits, with no guard here: an evaluate that calls a function with a breakpoint, right
//! after a step over a suspension point, loses every later stop at that breakpoint; while
//! stepping, breakpoints hit by other coroutines are skipped.
use ide_core::{IdeError, IdeResult};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
pub const ADAPTER: &str = "kotlin-lsp";
/// The `adapterID` its debug server expects in `initialize`.
pub const DAP_ADAPTER_ID: &str = "intellij_jvm";
/// The path of a `file:` URI, as kotlin-lsp reports some sources (percent-encoded); `None` for
/// anything else, including a URI naming another host.
pub(crate) fn source_path(path: &str) -> Option<PathBuf> {
let rest = path.strip_prefix("file:")?;
let path = match rest.strip_prefix("//") {
Some(authority) => {
let slash = authority.find('/')?;
if !matches!(&authority[..slash], "" | "localhost") {
return None;
}
&authority[slash..]
}
None => rest,
};
let decoded = percent_encoding::percent_decode_str(path).decode_utf8().ok()?;
// `/C:/p/App.kt` on Windows.
let decoded = match decoded.strip_prefix('/') {
Some(drive) if cfg!(windows) && drive.as_bytes().get(1) == Some(&b':') => drive,
_ => &decoded,
};
Some(PathBuf::from(decoded))
}
pub(crate) fn frame_name(name: &str) -> &str {
name.strip_prefix("at ").unwrap_or(name)
}
/// What kotlin-lsp's DAP server does unlike other adapters.
///
/// It never answers a request that carries a thread id from an earlier stop (K1 G4), so such a
/// request would wait out `REQUEST_TIMEOUT`: [`Self::check_thread`] refuses it first. Only thread
/// ids were measured; a stale `frameId` or `variablesReference` may wait the same way. A step
/// ends with `reason: "breakpoint"`; a real breakpoint hit during a step is then reported as a
/// step too, since the stop carries neither `hitBreakpointIds` nor a description.
#[derive(Default)]
pub(crate) struct Quirks {
step_pending: AtomicBool,
/// The stop (a `Suspension` ticket) and the thread ids seen in it.
threads: Mutex<(u64, HashSet<i64>)>,
}
impl Quirks {
/// Before `next`, `stepIn`, `stepOut` or a forced step.
pub fn step_sent(&self) {
self.step_pending.store(true, Ordering::Release);
}
/// Before `continue` or run to cursor, or after a step request failed.
pub fn resume_sent(&self) {
self.step_pending.store(false, Ordering::Release);
}
/// The reason to report for a `stopped` event.
pub fn stop_reason(&self, reason: String) -> String {
let stepped = self.step_pending.swap(false, Ordering::AcqRel);
if stepped && reason == "breakpoint" { "step".into() } else { reason }
}
/// Thread ids seen in stop `stop`: a `stopped` event's, or a `threads` reply to a request sent
/// during that stop. A reply from an older stop changes nothing.
pub fn saw_threads(&self, stop: u64, ids: impl IntoIterator<Item = i64>) {
let mut threads = self.threads.lock();
if stop > threads.0 {
*threads = (stop, ids.into_iter().collect());
} else if stop == threads.0 {
threads.1.extend(ids);
}
}
/// Refuses a thread id that is not one of current stop `stop`'s, once some are known.
pub fn check_thread(&self, stop: u64, suspended: bool, id: i64) -> IdeResult<()> {
let threads = self.threads.lock();
if suspended && threads.0 == stop && !threads.1.is_empty() && !threads.1.contains(&id) {
return Err(IdeError::invalid(format!("thread {id} belongs to an earlier stop")));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn file_uris_become_paths() {
assert_eq!(source_path("file:///p/a%20b/App.kt").as_deref(), Some(Path::new("/p/a b/App.kt")));
assert_eq!(source_path("file://localhost/p/x.kt").as_deref(), Some(Path::new("/p/x.kt")));
assert_eq!(source_path("/p/x.kt"), None);
}
#[test]
fn frame_names_lose_the_at_prefix() {
assert_eq!(frame_name("at com.x.AppKt.main(App.kt:13)"), "com.x.AppKt.main(App.kt:13)");
assert_eq!(frame_name("main"), "main");
}
#[test]
fn only_a_pending_step_turns_a_breakpoint_into_a_step() {
let quirks = Quirks::default();
quirks.step_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "step");
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
quirks.step_sent();
quirks.resume_sent();
assert_eq!(quirks.stop_reason("breakpoint".into()), "breakpoint");
}
#[test]
fn threads_are_checked_against_the_current_stop() {
let quirks = Quirks::default();
quirks.saw_threads(3, [18]);
assert!(quirks.check_thread(3, true, 18).is_ok());
assert!(quirks.check_thread(3, true, 6).is_err());
assert!(quirks.check_thread(4, true, 6).is_ok(), "nothing known about stop 4 yet");
assert!(quirks.check_thread(3, false, 6).is_ok());
}
#[test]
fn an_older_threads_reply_does_not_change_a_newer_stop() {
let quirks = Quirks::default();
quirks.saw_threads(4, [2]);
quirks.saw_threads(3, [1]);
assert!(quirks.check_thread(4, true, 1).is_err());
quirks.saw_threads(4, [7]);
assert!(quirks.check_thread(4, true, 2).is_ok() && quirks.check_thread(4, true, 7).is_ok());
quirks.saw_threads(5, [9]);
assert!(quirks.check_thread(5, true, 2).is_err());
}
}
+48 -8
View File
@@ -15,6 +15,7 @@ pub mod connection;
pub mod jdwp;
mod jvm_memory;
pub mod kotlin;
pub mod kotlin_lsp;
mod session;
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
@@ -610,6 +611,16 @@ 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
@@ -981,6 +992,7 @@ impl DebugManager {
arguments["projectRoot"] = json!(mirror.root());
*s.kotlin_mirror.lock() = Some(mirror);
}
let is_kotlin_lsp = s.adapter == kotlin_lsp::ADAPTER;
let caps = s
.conn
.request_timeout(
@@ -988,7 +1000,7 @@ impl DebugManager {
json!({
"clientID": "openidea",
"clientName": "OpenIDEA",
"adapterID": s.adapter,
"adapterID": if is_kotlin_lsp { kotlin_lsp::DAP_ADAPTER_ID } else { s.adapter.as_str() },
"locale": "en",
"linesStartAt1": true,
"columnsStartAt1": true,
@@ -998,7 +1010,8 @@ impl DebugManager {
"supportsRunInTerminalRequest": true,
"supportsStartDebuggingRequest": true,
"supportsProgressReporting": true,
"supportsInvalidatedEvent": false,
// kotlin-lsp sends `invalidated` either way.
"supportsInvalidatedEvent": is_kotlin_lsp,
"supportsMemoryReferences": false,
"supportsArgsCanBeInterpretedByShell": false,
}),
@@ -1008,7 +1021,7 @@ impl DebugManager {
*s.caps.lock() = if caps.is_object() { caps } else { json!({}) };
// The Kotlin adapters ignore the resolved classpath and project runtime on launch.
let request = if s.adapter == kotlin::ADAPTER && s.request == "launch" {
let request = if kotlin::is_kotlin(&s.adapter) && s.request == "launch" {
session::start_kotlin_debuggee(s, &mut arguments).await?;
"attach"
} else {
@@ -1032,7 +1045,7 @@ impl DebugManager {
jvm_memory::find_attached_jvm(s, port, finder).await;
}
}
if s.adapter == kotlin::ADAPTER {
if kotlin::is_kotlin(&s.adapter) {
let host = arguments
.get("hostName")
.and_then(Value::as_str)
@@ -1044,7 +1057,7 @@ impl DebugManager {
.and_then(|p| u16::try_from(p).ok())
.filter(|p| *p != 0)
.ok_or_else(|| IdeError::invalid("Invalid Kotlin JDWP port"))?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options()).await?;
let (link, port) = ide_jdwp::JdwpLink::start(host, port, kotlin::link_options(&s.adapter)).await?;
*s.jvm_inspector.lock() = Some(Arc::new(ide_jdwp::LinkInspector::new(link.clone())));
jvm_memory::watch_link(s, &link);
*s.jdwp_link.lock() = Some(link);
@@ -1381,6 +1394,8 @@ impl DebugManager {
pub async fn threads(&self, s: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
let session = self.session(s)?;
// Before sending: a stop that overtakes the reply must not take the reply's ids as its own.
let stop = session.suspended.ticket();
let body = session.conn.request_timeout("threads", Value::Null, REQUEST_TIMEOUT).await?;
let threads: Vec<ThreadInfo> = body
.get("threads")
@@ -1394,11 +1409,15 @@ impl DebugManager {
.collect()
})
.unwrap_or_default();
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.saw_threads(stop, threads.iter().map(|t| t.id));
}
Ok(threads)
}
pub async fn stack_trace(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<Vec<StackFrame>> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
let body = session
.conn
.request_timeout(
@@ -1407,11 +1426,15 @@ impl DebugManager {
REQUEST_TIMEOUT,
)
.await?;
let kotlin_lsp = session.kotlin_lsp_quirks().is_some();
let frame = |f: &Value| {
let mut frame = frame_from(f);
if let Some(client) = frame.path.as_deref().and_then(|p| session.client_path(&p.to_string_lossy())) {
frame.path = Some(client.into());
}
if kotlin_lsp {
frame.name = kotlin_lsp::frame_name(&frame.name).to_owned();
}
frame
};
Ok(body.get("stackFrames").and_then(Value::as_array).map(|f| f.iter().map(frame).collect()).unwrap_or_default())
@@ -1523,6 +1546,10 @@ impl DebugManager {
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.resume_sent();
}
let ticket = session.suspended.ticket();
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
// DAP: a response without the flag continued every thread.
@@ -1567,8 +1594,13 @@ impl DebugManager {
StepKind::Out => "stepOut",
};
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
let sent = session.conn.request_timeout(command, json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await;
step_settled(&session, sent)?;
// A step runs its thread; other threads stopped by their own events stay stopped.
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
@@ -1587,6 +1619,8 @@ impl DebugManager {
force: bool,
) -> IdeResult<()> {
let session = self.session(s)?;
// Before touching breakpoints: a refused `continue` would leave them replaced.
session.check_thread(thread_id)?;
if force {
self.suspend_breakpoints(&session, Some(&path)).await?;
}
@@ -1661,6 +1695,7 @@ impl DebugManager {
/// Force Step Over: `next` with every breakpoint suspended until the step ends.
pub async fn force_step_over(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
self.suspend_breakpoints(&session, None).await?;
self.step(s, thread_id, StepKind::Over).await
}
@@ -1668,11 +1703,16 @@ impl DebugManager {
/// Step into a specific call of the current line (Smart Step Into).
pub async fn step_in_target(&self, s: DebugSessionId, thread_id: i64, target_id: i64) -> IdeResult<()> {
let session = self.session(s)?;
session.check_thread(thread_id)?;
if let Some(quirks) = session.kotlin_lsp_quirks() {
quirks.step_sent();
}
let ticket = session.suspended.ticket();
session
let sent = session
.conn
.request_timeout("stepIn", json!({ "threadId": thread_id, "targetId": target_id }), REQUEST_TIMEOUT)
.await?;
.await;
step_settled(&session, sent)?;
session.suspended.resumed(ticket, thread_id, false);
session.emit(ide_core::event::DebugEvent::Continued { thread_id, all_threads_continued: true });
Ok(())
+34 -1
View File
@@ -3,6 +3,7 @@
use crate::adapter::AdapterSpec;
use crate::connection::{Connection, DapError, Incoming};
use crate::kotlin_lsp;
use crate::{Breakpoint, BreakpointState, DataBreakpoint, DebugManager, FunctionBreakpoint, SessionOwner};
use ide_core::event::DebugEvent;
#[cfg(unix)]
@@ -103,6 +104,8 @@ pub(crate) struct Session {
pub jvm_located: watch::Sender<bool>,
pub kotlin_mirror: Mutex<Option<crate::kotlin::SourceMirror>>,
pub jvm_memory: tokio::sync::Mutex<crate::JvmMemoryState>,
/// Used only by a kotlin-lsp session: see [`Session::kotlin_lsp_quirks`].
pub(crate) quirks: kotlin_lsp::Quirks,
}
/// Which threads of the program are suspended: each from its `stopped` event until a
@@ -252,6 +255,19 @@ impl Session {
jvm_located: watch::channel(false).0,
kotlin_mirror: Mutex::new(None),
jvm_memory: tokio::sync::Mutex::default(),
quirks: kotlin_lsp::Quirks::default(),
}
}
pub fn kotlin_lsp_quirks(&self) -> Option<&kotlin_lsp::Quirks> {
(self.adapter == kotlin_lsp::ADAPTER).then_some(&self.quirks)
}
/// Call before sending a request for thread `id`.
pub fn check_thread(&self, id: i64) -> ide_core::IdeResult<()> {
match self.kotlin_lsp_quirks() {
Some(quirks) => quirks.check_thread(self.suspended.ticket(), self.suspended.get(), id),
None => Ok(()),
}
}
@@ -283,6 +299,11 @@ impl Session {
/// A path from the adapter as the IDE knows it (undoes [`Session::adapter_path`]).
pub fn client_path(&self, path: &str) -> Option<PathBuf> {
if self.adapter == kotlin_lsp::ADAPTER
&& let Some(path) = kotlin_lsp::source_path(path)
{
return Some(path);
}
if let Some(real) = self.kotlin_mirror.lock().as_ref().and_then(|m| m.client_path(Path::new(path))) {
return Some(real);
}
@@ -697,7 +718,11 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
"stopped" => {
let all = body.get("allThreadsStopped").and_then(Value::as_bool).unwrap_or(false);
s.suspended.stopped(int("threadId"), all);
let reason = str_("reason").unwrap_or_else(|| "pause".into());
let mut reason = str_("reason").unwrap_or_else(|| "pause".into());
if let Some(quirks) = s.kotlin_lsp_quirks() {
quirks.saw_threads(s.suspended.ticket(), int("threadId"));
reason = quirks.stop_reason(reason);
}
mgr.restore_breakpoints(s).await;
s.emit(DebugEvent::Stopped {
thread_id: int("threadId").unwrap_or(0),
@@ -799,6 +824,14 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
change_type: crate::hot_code_replace_type(body.get("changeType")),
message: str_("message").unwrap_or_default(),
}),
// Sent only by adapters told `supportsInvalidatedEvent` (kotlin-lsp).
"invalidated" => s.emit(DebugEvent::Invalidated {
areas: body
.get("areas")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
}),
_ => tracing::trace!("dap: ignored event {event}"),
}
}
+286 -2
View File
@@ -126,8 +126,22 @@ struct Script {
continued_after_stop: bool,
/// Close the connection when a `pause` request arrives (the adapter dies).
hang_up_on_pause: bool,
/// Close the connection at the first request after the first `stopped` event.
hang_up_after_stop: bool,
/// Answer `continue` with this body and keep the program running, instead of letting it exit.
continue_reply: Option<Value>,
/// Answer `continue` and stop at a breakpoint again, instead of letting the program exit.
stop_after_continue: bool,
/// The `reason` of the stop a `next` ends with (`step` when unset; kotlin-lsp says `breakpoint`).
step_reason: Option<&'static str>,
/// Refuse `next`, then stop at a breakpoint (one the program hit meanwhile).
refuse_next: bool,
/// Send `invalidated { areas: ["variables"] }` right after the first `stopped` event.
invalidated_after_stop: bool,
/// On `threads`: stop again on thread 2 first, then answer with thread 1 of the earlier stop.
stop_during_threads: bool,
/// Name of the first stack frame (`main` when unset).
frame_name: Option<&'static str>,
/// Answer `initialize` only after this long (a slow adapter keeps the session connecting).
delay_initialize: Option<Duration>,
initialized_delay: Duration,
@@ -156,7 +170,14 @@ impl Default for Script {
hold_set_breakpoints: 0,
continued_after_stop: false,
hang_up_on_pause: false,
hang_up_after_stop: false,
continue_reply: None,
stop_after_continue: false,
step_reason: None,
refuse_next: false,
invalidated_after_stop: false,
stop_during_threads: false,
frame_name: None,
delay_initialize: None,
initialized_delay: Duration::ZERO,
launch_delay: Duration::ZERO,
@@ -202,6 +223,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut stopped = false;
let mut running = false;
macro_rules! send {
($v:expr) => {{
@@ -222,6 +244,9 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
if msg["type"] == "response" {
continue;
}
if script.hang_up_after_stop && stopped {
break;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => {
if let Some(delay) = script.delay_initialize {
@@ -320,9 +345,18 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "restart", "threadId": 1}}));
}
"next" if script.refuse_next => {
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "next", "success": false, "message": "refused"})
);
// Lets the client handle the refusal before the stop.
tokio::time::sleep(Duration::from_millis(50)).await;
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1}}));
}
"next" => {
ok!(msg, Value::Null);
send!(json!({"type": "event", "event": "stopped", "body": {"reason": "step", "threadId": 1}}));
let reason = script.step_reason.unwrap_or("step");
send!(json!({"type": "event", "event": "stopped", "body": {"reason": reason, "threadId": 1}}));
}
"setVariable" => {
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
@@ -358,6 +392,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
stopped = true;
if script.invalidated_after_stop {
send!(
json!({"type": "event", "event": "invalidated", "body": {"areas": ["variables"], "threadId": 1}})
);
}
if script.continued_after_stop {
send!(json!({"type": "event", "event": "continued", "body": {"threadId": 1}}));
}
@@ -365,11 +405,19 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
json!({"type": "event", "event": "breakpoint", "body": {"reason": "changed", "breakpoint": {"id": 1, "verified": false, "message": "moved", "line": 9, "source": {"path": script.source_path}}}})
);
}
"threads" if script.stop_during_threads => {
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 2, "allThreadsStopped": true}})
);
// Lets the client handle the stop before it reads the reply.
tokio::time::sleep(Duration::from_millis(50)).await;
ok!(msg, json!({"threads": [{"id": 1, "name": "\"main\"@1"}]}));
}
"threads" => ok!(msg, json!({"threads": [{"id": 1, "name": "MainThread"}]})),
"stackTrace" => ok!(
msg,
json!({"stackFrames": [
{"id": 10, "name": "main", "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 10, "name": script.frame_name.unwrap_or("main"), "line": 5, "column": 3, "source": {"path": script.source_path.as_deref().unwrap_or("/p/a.py")}},
{"id": 11, "name": "lib", "line": 1, "column": 1, "source": {"path": "/usr/lib/x.py", "presentationHint": "deemphasize"}},
]})
),
@@ -393,6 +441,12 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
]})
),
"continue" if script.continue_reply.is_some() => ok!(msg, script.continue_reply.clone().unwrap()),
"continue" if script.stop_after_continue => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(
json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}})
);
}
"continue" => {
ok!(msg, json!({"allThreadsContinued": true}));
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
@@ -2401,6 +2455,13 @@ fn kotlin_attach(project: &std::path::Path, host: &str, port: u16) -> LaunchConf
}
}
#[test]
fn link_options_hold_only_for_fwcd() {
assert!(kotlin::link_options(kotlin::ADAPTER).hold_resumes);
assert!(!kotlin::link_options(kotlin_lsp::ADAPTER).hold_resumes);
assert!(!kotlin::link_options("x").hold_resumes);
}
#[tokio::test]
async fn kotlin_session_installs_link_inspector() {
let h = harness(Script::default());
@@ -2472,6 +2533,229 @@ async fn closing_the_session_drops_the_link() {
assert!(session.jvm_inspector.lock().is_none());
}
/// kotlin-lsp's debugger, reached through the language server's `debugServer` port (the fake ignores it).
fn kotlin_lsp_attach(project: &std::path::Path) -> LaunchConfig {
LaunchConfig {
adapter: kotlin_lsp::ADAPTER.into(),
request: "attach".into(),
cwd: Some(project.to_owned().into()),
extra_json: Some(
json!({"debugServer": 4711, "hostName": "127.0.0.1", "port": 5005, "projectRoot": project}).to_string(),
),
..config()
}
}
#[tokio::test]
async fn kotlin_lsp_initializes_as_intellij_jvm() {
let project = tempfile::tempdir().unwrap();
for (config, adapter_id, invalidated) in [
(kotlin_lsp_attach(project.path()), "intellij_jvm", true),
(kotlin_attach(project.path(), "127.0.0.1", 5005), "kotlin-debug-adapter", false),
] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
let init = &h.request("initialize")["arguments"];
assert_eq!(init["adapterID"], adapter_id);
assert_eq!(init["supportsInvalidatedEvent"], invalidated, "{adapter_id}");
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn kotlin_lsp_gets_real_paths_and_its_link_holds_nothing() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let path = project.path().join("Main.kt");
std::fs::write(&path, "package org.example\nfun main() {}\n").unwrap();
h.manager.set_breakpoints(path.clone(), vec![bp(1)]).await.unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert_eq!(h.request("setBreakpoints")["arguments"]["source"]["path"], json!(path));
let sent = &h.request("attach")["arguments"];
assert_eq!(sent["projectRoot"], json!(project.path()));
assert!(sent.get("debugServer").is_none());
assert_eq!(sent["hostName"], "127.0.0.1");
let link_port = sent["port"].as_u64().unwrap();
assert!(link_port != 0 && link_port != 5005);
let session = h.manager.session(id).unwrap();
assert!(session.kotlin_mirror.lock().is_none());
assert!(session.jdwp_link.lock().is_some() && session.jvm_inspector.lock().is_some());
assert_eq!(session.attach_target.get(), Some(&("127.0.0.1".to_owned(), 5005)));
assert!(!kotlin::link_options(&session.adapter).hold_resumes);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_waits_for_the_configuration_done_reply() {
let project = tempfile::tempdir().unwrap();
// fwcd never answers it (`kotlin_configures_without_configuration_done_response`).
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let fwcd = h.start(kotlin_attach(project.path(), "127.0.0.1", 5005));
let id = tokio::time::timeout(Duration::from_secs(2), fwcd).await.unwrap().unwrap();
h.manager.stop(id).await.unwrap();
tokio::time::pause();
let h = harness(Script { ignore_configuration_done: true, ..Default::default() });
let started = tokio::time::Instant::now();
let err = h.start(kotlin_lsp_attach(project.path())).await.unwrap_err();
assert!(started.elapsed() >= REQUEST_TIMEOUT, "{:?}", started.elapsed());
assert!(err.message.contains("configurationDone"), "{err:?}");
assert!(h.manager.sessions(None).is_empty());
}
#[tokio::test]
async fn kotlin_lsp_sessions_have_the_memory_tab() {
let h = harness(Script::default());
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
assert!(h.manager.capabilities(id).unwrap().jvm_memory);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn losing_the_kotlin_lsp_connection_ends_the_session() {
let h = harness(Script { hang_up_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let session = h.manager.session(id).unwrap();
assert!(session.jdwp_link.lock().is_some());
let _ = session.conn.request_timeout("threads", Value::Null, Duration::from_secs(2)).await;
h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await;
assert!(h.manager.sessions(None).is_empty());
assert!(session.jdwp_link.lock().is_none());
assert!(session.jvm_inspector.lock().is_none());
}
/// The reasons of the first `count` stops, once that many arrived.
async fn stop_reasons(h: &Harness, count: usize) -> Vec<String> {
for _ in 0..200 {
let reasons: Vec<String> = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Stopped { reason, .. }, .. } => Some(reason.clone()),
_ => None,
})
.collect();
if reasons.len() >= count {
return reasons;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("{count} stops not received; got {:?}", h.events.lock());
}
#[tokio::test]
async fn a_step_stop_is_reported_as_a_step() {
let project = tempfile::tempdir().unwrap();
for (config, reason) in [(kotlin_lsp_attach(project.path()), "step"), (config(), "breakpoint")] {
let h = harness(Script { step_reason: Some("breakpoint"), ..Script::default() });
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", reason]);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_breakpoint_after_continue_stays_a_breakpoint() {
let h = harness(Script { step_reason: Some("breakpoint"), stop_after_continue: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
h.manager.step(id, 1, StepKind::Over).await.unwrap();
stop_reasons(&h, 2).await;
// The fake ends `stepIn` with no stop: the `continue` after it decides the next one.
h.manager.step(id, 1, StepKind::Into).await.unwrap();
h.manager.resume(id, 1).await.unwrap();
assert_eq!(stop_reasons(&h, 3).await, ["breakpoint", "step", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_breakpoint_after_a_refused_step_stays_a_breakpoint() {
let h = harness(Script { refuse_next: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert!(h.manager.step(id, 1, StepKind::Over).await.is_err());
assert_eq!(stop_reasons(&h, 2).await, ["breakpoint", "breakpoint"]);
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn a_thread_from_an_earlier_stop_is_refused() {
let project = tempfile::tempdir().unwrap();
for (config, kotlin_lsp) in [(kotlin_lsp_attach(project.path()), true), (config(), false)] {
let h = harness(Script::default());
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
assert_eq!(h.manager.threads(id).await.unwrap().len(), 1);
let started = tokio::time::Instant::now();
let stale = h.manager.stack_trace(id, 99).await;
let asked =
|command: &str| h.log.lock().iter().any(|m| m["command"] == command && m["arguments"]["threadId"] == 99);
if kotlin_lsp {
assert!(stale.is_err() && started.elapsed() < Duration::from_millis(100), "{stale:?}");
assert!(h.manager.step(id, 99, StepKind::Over).await.is_err());
assert!(h.manager.resume(id, 99).await.is_err());
assert!(!asked("stackTrace") && !asked("next") && !asked("continue"));
} else {
assert!(stale.is_ok() && asked("stackTrace"), "{stale:?}");
}
assert!(h.manager.stack_trace(id, 1).await.is_ok());
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn a_threads_reply_from_before_a_stop_does_not_vouch_for_its_ids() {
let h = harness(Script { stop_during_threads: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let threads = h.manager.threads(id).await.unwrap();
// M3 maps kotlin-lsp's threads by these names.
assert_eq!((threads[0].id, threads[0].name.as_str()), (1, "\"main\"@1"));
stop_reasons(&h, 2).await;
assert!(h.manager.stack_trace(id, 1).await.is_err());
assert!(h.manager.stack_trace(id, 2).await.is_ok());
h.manager.stop(id).await.unwrap();
}
#[tokio::test]
async fn kotlin_lsp_frames_get_real_paths() {
let project = tempfile::tempdir().unwrap();
let at = "at com.x.AppKt.main(App.kt:13)";
for (config, path, name) in [
(kotlin_lsp_attach(project.path()), "/p/App.kt", "com.x.AppKt.main(App.kt:13)"),
(java_config("launch"), "file:///p/App.kt", at),
] {
let script = Script { source_path: Some("file:///p/App.kt".into()), frame_name: Some(at), ..Script::default() };
let h = harness(script);
let id = h.start(config).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
let frames = h.manager.stack_trace(id, 1).await.unwrap();
assert_eq!(frames[0].path.as_ref().map(|p| p.0.clone()), Some(PathBuf::from(path)));
assert_eq!(frames[0].name, name);
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test]
async fn invalidated_events_reach_the_client() {
let h = harness(Script { invalidated_after_stop: true, ..Script::default() });
let project = tempfile::tempdir().unwrap();
let id = h.start(kotlin_lsp_attach(project.path())).await.unwrap();
match h.wait_event(|e| matches!(e, DebugEvent::Invalidated { .. })).await {
DebugEvent::Invalidated { areas } => assert_eq!(areas, ["variables"]),
other => panic!("{other:?}"),
}
h.manager.stop(id).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
+320 -26
View File
@@ -9,6 +9,14 @@ use std::path::{Path, PathBuf};
/// configuration and roaming data keep following the OS.
pub const LOCAL_DATA_ENV: &str = "OPENIDEA_LOCAL_DATA_DIR";
/// A custom local-data root chosen in Settings › System Settings › Caches,
/// stored in the configuration directory. The environment variable still wins.
pub const OVERRIDE_FILE: &str = "local-data-dir.txt";
/// A pending move of the local data to a newly chosen directory, carried out
/// at the next start (before any project opens), like File › Invalidate Caches.
const MIGRATION_FILE: &str = "local-data-migration.json";
/// `$OPENIDEA_LOCAL_DATA_DIR/<identifier>` when set, else the OS local data directory
/// (Tauri's `app_local_data_dir`). The identifier keeps release and per-worktree
/// debug builds apart under one root. A set but unusable path is an error, never a
@@ -17,6 +25,203 @@ pub fn local_data_dir(identifier: &str) -> Result<PathBuf, String> {
resolve_local(std::env::var_os(LOCAL_DATA_ENV), dirs::data_local_dir(), identifier)
}
/// The directory to actually start with: the environment override, else a valid
/// custom directory from Settings, else the OS default. A stored but unusable
/// custom directory is an error, never a silent fallback.
pub fn startup_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
startup_resolve(std::env::var_os(LOCAL_DATA_ENV), stored_override(identifier), dirs::data_local_dir(), identifier)
}
fn startup_resolve(
var: Option<OsString>,
stored: Option<PathBuf>,
os: Option<PathBuf>,
identifier: &str,
) -> Result<PathBuf, String> {
if var.as_ref().is_some_and(|v| !v.is_empty()) {
return resolve_local(var, os, identifier);
}
let Some(root) = stored else { return resolve_local(None, os, identifier) };
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, "custom local data directory")
}
/// The file holding the custom local-data root, if the platform exposes its
/// configuration directory. Both startup and Settings use this one path.
pub fn override_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(OVERRIDE_FILE))
}
fn migration_path(identifier: &str) -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join(identifier).join(MIGRATION_FILE))
}
/// The custom root stored by Settings, if any (validated when it is used).
pub fn stored_override(identifier: &str) -> Option<PathBuf> {
stored_override_in(&override_path(identifier)?)
}
fn stored_override_in(path: &Path) -> Option<PathBuf> {
let text = fs::read_to_string(path).ok()?;
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| PathBuf::from(trimmed))
}
/// The OS default local data directory (what clearing the custom directory
/// returns to), ignoring any stored override. The environment still applies.
pub fn default_local_data_dir(identifier: &str) -> Result<PathBuf, String> {
local_data_dir(identifier)
}
/// Stores (`Some`) or clears (`None`) the custom local-data root, returning the
/// directory that takes effect after a restart. A pending custom migration to
/// anywhere else is dropped: only the caller schedules the matching one.
pub fn set_override(identifier: &str, root: Option<&Path>) -> Result<PathBuf, String> {
let Some(path) = override_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
set_override_in(&path, &migration_path(identifier), root, identifier)
}
fn set_override_in(
path: &Path,
migration: &Option<PathBuf>,
root: Option<&Path>,
identifier: &str,
) -> Result<PathBuf, String> {
if let Some(migration) = migration {
let _ = fs::remove_file(migration);
}
let Some(root) = root else {
if let Err(error) = fs::remove_file(path)
&& error.kind() != io::ErrorKind::NotFound
{
return Err(format!("cannot clear the custom local data directory: {error}"));
}
return local_data_dir(identifier);
};
if !root.is_absolute() {
return Err(format!("custom local data directory must be an absolute path: {}", root.display()));
}
let dir = resolve_root(root.to_path_buf(), identifier, "custom local data directory")?;
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot store the custom local data directory: {error}"));
}
if let Err(error) = fs::write(path, format!("{}\n", root.display())) {
return Err(format!("cannot store the custom local data directory: {error}"));
}
Ok(dir)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CustomMigration {
from: PathBuf,
to: PathBuf,
}
/// Schedules a move of the local data from `from` to `to` at the next start.
/// Returns false when there is nothing to move. The caller restarts the IDE.
pub fn schedule_custom_migration(identifier: &str, from: &Path, to: &Path) -> Result<bool, String> {
let Some(path) = migration_path(identifier) else {
return Err("cannot find the configuration directory".into());
};
schedule_custom_migration_in(&path, from, to)
}
fn schedule_custom_migration_in(path: &Path, from: &Path, to: &Path) -> Result<bool, String> {
if from == to {
return Ok(false);
}
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
return Err(format!("cannot schedule the data move: {error}"));
}
let json = serde_json::to_vec(&CustomMigration { from: from.into(), to: to.into() })
.map_err(|error| format!("cannot schedule the data move: {error}"))?;
if let Err(error) = fs::write(path, json) {
return Err(format!("cannot schedule the data move: {error}"));
}
Ok(true)
}
/// Carries out a scheduled custom migration aimed at `local` (call at start-up,
/// before projects open). Entries that appear in the meantime are never
/// overwritten; a partially moved tree is retried at the next start.
pub fn apply_custom_migration(identifier: &str, local: &Path) {
let Some(path) = migration_path(identifier) else { return };
apply_custom_migration_in(&path, local);
}
fn apply_custom_migration_in(path: &Path, local: &Path) {
let Ok(bytes) = fs::read(path) else { return };
let Ok(job) = serde_json::from_slice::<CustomMigration>(&bytes) else {
let _ = fs::remove_file(path);
return;
};
if job.to != local || job.from == local {
let _ = fs::remove_file(path);
return;
}
move_entries(&job.from, local, &custom_entries(&job.from));
let leftover = custom_entries(&job.from).into_iter().any(|entry| job.from.join(entry).exists());
if !leftover {
let _ = fs::remove_file(path);
}
}
/// Entries of a local-data tree that move to a newly chosen directory.
/// Non-relocatable tool installs (virtual environments with absolute interpreter
/// paths) stay behind and are reinstalled on demand, as in `migrate_entries`.
fn custom_entries(root: &Path) -> Vec<PathBuf> {
let mut entries = local_entries(root);
for extra in ["logs", "agents/tools"] {
let entry = PathBuf::from(extra);
if !entries.contains(&entry) {
entries.push(entry);
}
}
entries
}
/// Moves the local-data tree from `from` to `to`, entry by entry. Same-volume
/// entries rename at once; cross-volume copies run in the background. Targets
/// created in the meantime are never merged into or overwritten.
fn move_entries(from: &Path, to: &Path, entries: &[PathBuf]) {
if from == to {
return;
}
let mut copies = Vec::new();
for entry in entries {
let source = from.join(entry);
let target = to.join(entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data move failed; keeping old data, using new location")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying data to the new location in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data");
}
}
}
fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -> Result<PathBuf, String> {
let Some(root) = var.filter(|v| !v.is_empty()).map(PathBuf::from) else {
return os.map(|dir| dir.join(identifier)).ok_or_else(|| "cannot find the local data directory".into());
@@ -25,10 +230,14 @@ fn resolve_local(var: Option<OsString>, os: Option<PathBuf>, identifier: &str) -
if !root.is_absolute() {
return Err(format!("{LOCAL_DATA_ENV} must be an absolute path: {}", root.display()));
}
resolve_root(root, identifier, LOCAL_DATA_ENV)
}
fn resolve_root(root: PathBuf, identifier: &str, what: &str) -> Result<PathBuf, String> {
let dir = root.join(identifier);
fs::create_dir_all(&dir)
.and_then(|()| tempfile::NamedTempFile::new_in(&dir).map(drop))
.map_err(|error| format!("{LOCAL_DATA_ENV}: cannot write to {}: {error}", dir.display()))?;
.map_err(|error| format!("{what}: cannot write to {}: {error}", dir.display()))?;
Ok(dir)
}
@@ -77,6 +286,11 @@ pub fn migrate(data: &Path, local: &Path) {
}
fn migrate_entries(data: &Path, local: &Path) {
move_entries(data, local, &local_entries(data));
}
/// The rebuildable entries of the legacy roaming tree that move to Local.
fn local_entries(data: &Path) -> Vec<PathBuf> {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
@@ -118,31 +332,7 @@ fn migrate_entries(data: &Path, local: &Path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
entries
}
/// Returns true when a cross-volume copy is needed.
@@ -597,4 +787,108 @@ mod tests {
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
#[test]
fn custom_override_round_trips_and_validates() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("local-data-dir.txt");
let migration = tmp.path().join("local-data-migration.json");
let id = "ai.test.openidea";
assert_eq!(stored_override_in(&file), None);
let root = tmp.path().join("custom-root");
let dir = set_override_in(&file, &Some(migration.clone()), Some(&root), id).unwrap();
assert_eq!(dir, root.join(id));
assert!(dir.is_dir());
assert_eq!(stored_override_in(&file), Some(root.clone()));
assert_eq!(startup_resolve(None, stored_override_in(&file), Some(tmp.path().join("os")), id).unwrap(), dir);
// A relative root is rejected before anything is written.
let before = fs::read_to_string(&file).unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(Path::new("relative")), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
assert_eq!(fs::read_to_string(&file).unwrap(), before);
// An unwritable root (an existing file) is rejected too.
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "").unwrap();
let error = set_override_in(&file, &Some(migration.clone()), Some(&blocker), id).unwrap_err();
assert!(error.contains("cannot write"), "{error}");
// Clearing removes the file and returns the default.
let dir = set_override_in(&file, &Some(migration), None, id).unwrap();
assert!(!file.exists());
assert_eq!(dir, local_data_dir(id).unwrap());
assert_eq!(stored_override_in(&file), None);
}
#[test]
fn startup_prefers_the_environment_then_the_override() {
let tmp = tempfile::tempdir().unwrap();
let os = tmp.path().join("os");
let custom = tmp.path().join("custom");
let env = tmp.path().join("env");
let id = "ai.test.openidea";
let error = startup_resolve(None, Some(PathBuf::from("relative")), Some(os.clone()), id).unwrap_err();
assert!(error.contains("absolute"), "{error}");
// The environment wins over a stored override.
let dir = startup_resolve(Some(env.clone().into()), Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, env.join(id));
// Without the environment the override wins over the OS default.
let dir = startup_resolve(None, Some(custom.clone()), Some(os.clone()), id).unwrap();
assert_eq!(dir, custom.join(id));
// And without either the OS default applies.
let dir = startup_resolve(None, None, Some(os.clone()), id).unwrap();
assert_eq!(dir, os.join(id));
}
#[test]
fn custom_migration_moves_the_tree_and_clears_itself() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("local-data-migration.json");
let from = tmp.path().join("old-local");
let to = tmp.path().join("new-local");
fs::create_dir_all(from.join("index/p1")).unwrap();
fs::write(from.join("index/p1/symbols"), "index").unwrap();
fs::create_dir_all(from.join("cache/p1")).unwrap();
fs::write(from.join("cache/p1/build"), "cache").unwrap();
fs::create_dir_all(from.join("tools/demo/bin")).unwrap();
fs::write(from.join("tools/demo/bin/demo.cmd"), "payload").unwrap();
fs::write(from.join("history"), "roaming stays").unwrap();
// Nothing to move: no marker is written.
assert!(!schedule_custom_migration_in(&marker, &from, &from).unwrap());
assert!(!marker.exists());
assert!(schedule_custom_migration_in(&marker, &from, &to).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert_eq!(fs::read_to_string(to.join("index/p1/symbols")).unwrap(), "index");
assert_eq!(fs::read_to_string(to.join("cache/p1/build")).unwrap(), "cache");
assert!(to.join("tools/demo/bin/demo.cmd").exists());
assert!(!from.join("index").exists());
// Roaming content is not local data and stays behind.
assert_eq!(fs::read_to_string(from.join("history")).unwrap(), "roaming stays");
// A migration aimed elsewhere is dropped without touching anything.
fs::create_dir_all(from.join("lsp")).unwrap();
assert!(schedule_custom_migration_in(&marker, &from, &tmp.path().join("elsewhere")).unwrap());
apply_custom_migration_in(&marker, &to);
assert!(!marker.exists());
assert!(from.join("lsp").exists());
assert!(!to.join("lsp").exists());
}
#[test]
fn custom_entries_cover_logs_and_agent_tools() {
let tmp = tempfile::tempdir().unwrap();
let entries = custom_entries(tmp.path());
for expected in ["logs", "agents/tools", "index", "cache", "lsp", "agents/registry.json"] {
assert!(entries.contains(&PathBuf::from(expected)), "{entries:?}");
}
}
}
+36 -2
View File
@@ -5,8 +5,9 @@ use crate::Host;
use crate::rpc::Dispatcher;
use ide_agent::registry::{AgentRegistry, RegistryPaths};
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
NewAgentTask,
};
use ide_core::error::ErrorKind;
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
@@ -44,6 +45,24 @@ pub fn set_env(
host.project(window)?.agents.set_builtin_env(&id, env)
}
/// Saves a new custom ACP agent through its agent file.
pub fn save_custom_agent(host: &Host, window: &str, input: CustomAgentInput) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.save_custom_agent(input)
}
/// Removes a custom agent's file (registry installs go through `registry_remove`).
pub fn remove_custom_agent(host: &Host, window: &str, id: String) -> IdeResult<()> {
host.require_trust(window)?;
host.project(window)?.agents.remove_custom_agent(&id)
}
/// Imports a JetBrains `acp.json` file's `agent_servers` entries as custom agents.
pub fn import_acp_agents(host: &Host, window: &str, text: String) -> IdeResult<Vec<AcpImportOutcome>> {
host.require_trust(window)?;
host.project(window)?.agents.import_acp_agents(&text)
}
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
@@ -297,6 +316,18 @@ pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Op
host.project(window)?.agents.rollback_changes(&task, paths).await
}
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
/// local checkout, uncommitted; files the checkout changed itself are left alone and reported.
pub async fn accept_changes(
host: &Host,
window: &str,
task: String,
paths: Option<Vec<String>>,
) -> IdeResult<AgentAcceptOutcome> {
host.require_trust(window)?;
host.project(window)?.agents.accept_changes(&task, paths).await
}
type McpServerMap = std::collections::BTreeMap<String, ide_core::settings::AgentMcpServer>;
fn mcp_maps(host: &Host, window: &str) -> (McpServerMap, McpServerMap, Option<std::sync::Arc<crate::Project>>) {
@@ -449,6 +480,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("agent_rollback_changes", |c| async move {
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
d.add("agent_accept_changes", |c| async move {
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
});
}
fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult<std::path::PathBuf> {
+4
View File
@@ -1,3 +1,6 @@
mod kotlin;
#[cfg(test)]
mod kotlin_tests;
mod output;
use crate::Host;
@@ -41,6 +44,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
})
.await?;
}
config = kotlin::choose_kotlin_debugger(host, window, config).await?;
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
+415
View File
@@ -0,0 +1,415 @@
//! Kotlin debugging: kotlin-lsp's debugger when the project's kotlin-lsp can serve it, else
//! kotlin-debug-adapter with a notice saying why.
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::task::CancelHandle;
use ide_core::{CancellationToken, IdeEvent, IdeResult, SharedSink, TaskId};
use ide_dap::LaunchConfig;
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
use parking_lot::Mutex;
use std::collections::HashSet;
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
pub(super) const KOTLIN_IMPORT_WAIT: Duration = Duration::from_secs(120);
/// A ready kotlin-lsp answers well within this; only a longer wait shows a task.
const TASK_DELAY: Duration = Duration::from_millis(500);
// Catalogued as written (`{detail}` replaced at runtime): the text catalog test counts these
// literals as the use of its entries.
const TASK_TITLE: &str = "Waiting for kotlin-lsp to import the project";
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
const NOT_CONFIGURED: &str = "kotlin-lsp is not the Kotlin language server of this project.";
const NOT_INSTALLED: &str = "kotlin-lsp is not installed.";
const NOT_STARTED: &str = "kotlin-lsp did not start: {detail}";
const STILL_IMPORTING: &str =
"kotlin-lsp is still importing the project. Debug again once the import has finished to use its debugger.";
const IMPORT_FAILED: &str = "kotlin-lsp could not import the project.";
const FAILED: &str = "kotlin-lsp could not start its debugger: {detail}";
/// `config` for kotlin-lsp's debug server on `port`; the rest of the launch stays as it is.
pub(super) fn use_kotlin_lsp(mut config: LaunchConfig, port: u16) -> IdeResult<LaunchConfig> {
let mut extra = extra_object(&config)?;
extra.insert("debugServer".into(), port.into());
config.adapter = ide_dap::kotlin_lsp::ADAPTER.into();
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
Ok(config)
}
/// The dedupe key of `reason` and the notice body (English, translated by the UI's catalog).
pub(super) fn fallback_notice(reason: &KotlinDebugUnavailable) -> (&'static str, String) {
match reason {
KotlinDebugUnavailable::NotConfigured => ("notConfigured", NOT_CONFIGURED.into()),
KotlinDebugUnavailable::NotInstalled => ("notInstalled", NOT_INSTALLED.into()),
KotlinDebugUnavailable::NotStarted(detail) => ("notStarted", NOT_STARTED.replace("{detail}", detail)),
KotlinDebugUnavailable::StillImporting => ("stillImporting", STILL_IMPORTING.into()),
KotlinDebugUnavailable::ImportFailed => ("importFailed", IMPORT_FAILED.into()),
KotlinDebugUnavailable::Failed(detail) => ("failed", FAILED.replace("{detail}", detail)),
}
}
/// A kotlin-debug-adapter launch becomes a kotlin-lsp one when the project's kotlin-lsp has
/// imported the module and started its debug server; otherwise it stays on
/// kotlin-debug-adapter and the window is told why (once per reason).
pub(super) async fn choose_kotlin_debugger(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<LaunchConfig> {
if config.adapter == ide_dap::kotlin_lsp::ADAPTER {
require_debug_server(&config)?;
return Ok(config);
}
if config.adapter != ide_dap::kotlin::ADAPTER {
return Ok(config);
}
let Ok(project) = host.project(window) else {
tracing::info!(
"debugging Kotlin with kotlin-debug-adapter: {}",
fallback_notice(&KotlinDebugUnavailable::NotConfigured).1
);
return Ok(config);
};
let path = debug_path(&config).unwrap_or_else(|| project.root.clone());
let lsp = project.lsp.clone();
let chosen = wait_for_debug_server(&project.sink, |cancel| async move {
lsp.kotlin_debug_server(&path, KOTLIN_IMPORT_WAIT, &cancel).await
})
.await;
match chosen {
Ok(server) => use_kotlin_lsp(config, server.port),
Err(reason) => {
report_fallback(&project.kotlin_debug_notices, &project.sink, &reason);
Ok(config)
}
}
}
/// kotlin-lsp is only reached through the port its language server opened; without one
/// ide-dap would try to spawn a `kotlin-lsp` adapter that does not exist.
fn require_debug_server(config: &LaunchConfig) -> IdeResult<()> {
match extra_object(config)?.get("debugServer") {
None | Some(serde_json::Value::Null) => Err(ide_core::IdeError::invalid(
"A kotlin-lsp debug configuration needs \"debugServer\": the port of kotlin-lsp's debug server. \
Use kotlin-debug-adapter to let the IDE start it.",
)),
Some(_) => Ok(()),
}
}
fn extra_object(config: &LaunchConfig) -> IdeResult<serde_json::Map<String, serde_json::Value>> {
match config.extra_json.as_deref().map(serde_json::from_str).transpose() {
Ok(None) => Ok(serde_json::Map::new()),
Ok(Some(serde_json::Value::Object(extra))) => Ok(extra),
Ok(Some(other)) => Err(ide_core::IdeError::invalid(format!("launch arguments are not an object: {other}"))),
Err(e) => Err(ide_core::IdeError::invalid(e.to_string())),
}
}
/// The module to debug: the launch's `projectRoot`, else its working directory.
fn debug_path(config: &LaunchConfig) -> Option<PathBuf> {
let extra: Option<serde_json::Value> = config.extra_json.as_deref().and_then(|e| serde_json::from_str(e).ok());
extra
.as_ref()
.and_then(|e| e.get("projectRoot")?.as_str())
.filter(|root| !root.is_empty())
.map(PathBuf::from)
.or_else(|| config.cwd.as_deref().map(PathBuf::from))
}
/// Runs `debug_server` with a token the user can cancel; a wait longer than [`TASK_DELAY`]
/// shows as a cancellable task, which ends however the wait does (also when dropped).
async fn wait_for_debug_server<F, Fut>(
sink: &SharedSink,
debug_server: F,
) -> Result<KotlinDebugServer, KotlinDebugUnavailable>
where
F: FnOnce(CancellationToken) -> Fut,
Fut: Future<Output = Result<KotlinDebugServer, KotlinDebugUnavailable>>,
{
let cancel = CancellationToken::new();
let id = TaskId::next();
let handle = ide_core::task::on_cancel(id, {
let cancel = cancel.clone();
move || cancel.cancel()
});
let wait = debug_server(cancel);
tokio::pin!(wait);
if let Ok(answer) = tokio::time::timeout(TASK_DELAY, &mut wait).await {
return answer;
}
let _task = WaitTask::start(sink.clone(), id, handle);
wait.await
}
/// The visible import wait; dropping it ends the task.
struct WaitTask {
sink: SharedSink,
id: TaskId,
_cancel: CancelHandle,
}
impl WaitTask {
fn start(sink: SharedSink, id: TaskId, cancel: CancelHandle) -> Self {
let task = Self { sink, id, _cancel: cancel };
task.emit(false);
task
}
fn emit(&self, done: bool) {
self.sink.emit(IdeEvent::Task {
id: self.id,
title: TASK_TITLE.into(),
message: None,
fraction: None,
done,
cancellable: !done,
});
}
}
impl Drop for WaitTask {
fn drop(&mut self) {
self.emit(true);
}
}
/// Logs every fallback; notifies the window once per reason.
fn report_fallback(notices: &Mutex<HashSet<&'static str>>, sink: &SharedSink, reason: &KotlinDebugUnavailable) {
let (key, body) = fallback_notice(reason);
tracing::info!("debugging Kotlin with kotlin-debug-adapter: {body}");
if notices.lock().insert(key) {
sink.emit(IdeEvent::Notify { level: NotifyLevel::Info, title: NOTICE_TITLE.into(), body: Some(body) });
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Arc;
fn launch(adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some("/cwd".into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn extra(config: &LaunchConfig) -> serde_json::Value {
serde_json::from_str(config.extra_json.as_deref().unwrap()).unwrap()
}
fn reasons() -> Vec<KotlinDebugUnavailable> {
vec![
KotlinDebugUnavailable::NotConfigured,
KotlinDebugUnavailable::NotInstalled,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::ImportFailed,
KotlinDebugUnavailable::Failed("boom".into()),
]
}
fn recording_sink() -> (SharedSink, Arc<Mutex<Vec<IdeEvent>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let recorded = events.clone();
(Arc::new(move |event| recorded.lock().push(event)), events)
}
/// `(done, cancellable)` of each task event, with the task id.
fn tasks(events: &Mutex<Vec<IdeEvent>>) -> Vec<(ide_core::TaskId, String, bool, bool)> {
events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Task { id, title, done, cancellable, .. } => Some((*id, title.clone(), *done, *cancellable)),
_ => None,
})
.collect()
}
fn server() -> KotlinDebugServer {
KotlinDebugServer { port: 4711, root: PathBuf::from("/p") }
}
#[test]
fn kotlin_lsp_takes_the_debug_server_port() {
let config = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"mainClass": "a.AppKt", "projectRoot": "/p"}));
let c = use_kotlin_lsp(config, 4711).unwrap();
assert_eq!(c.adapter, ide_dap::kotlin_lsp::ADAPTER);
let args = extra(&c);
assert_eq!(args["debugServer"], 4711);
assert_eq!(args["mainClass"], "a.AppKt");
assert_eq!(args["projectRoot"], "/p");
assert!(args.get("__adapterJavaHome").is_none());
assert_eq!(
(c.name.as_str(), c.request.as_str(), c.cwd.as_deref()),
("AppKt", "launch", Some(Path::new("/cwd")))
);
let mut bare = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert_eq!(extra(&use_kotlin_lsp(bare, 4711).unwrap())["debugServer"], 4711);
}
#[test]
fn every_fallback_reason_has_a_catalogued_text() {
let catalogs: [(&str, serde_json::Value); 2] = [
("en", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/en.json")).unwrap()),
("zh", serde_json::from_str(include_str!("../../../../../src/platform/locales/text/zh.json")).unwrap()),
];
let templates = [NOT_CONFIGURED, NOT_INSTALLED, NOT_STARTED, STILL_IMPORTING, IMPORT_FAILED, FAILED];
for reason in reasons() {
let (_, body) = fallback_notice(&reason);
let detail = match &reason {
KotlinDebugUnavailable::NotStarted(d) | KotlinDebugUnavailable::Failed(d) => d.as_str(),
_ => "",
};
assert!(
templates.iter().any(|t| t.replace("{detail}", detail) == body),
"{reason:?}: {body:?} is not built from a catalogued template"
);
}
for text in templates.iter().chain([&NOTICE_TITLE, &TASK_TITLE]) {
for (locale, catalog) in &catalogs {
let translated = catalog[*text].as_str().unwrap_or_else(|| panic!("{locale}.json lacks {text:?}"));
if *locale == "zh" {
assert_ne!(translated, *text, "zh.json leaves {text:?} untranslated");
}
}
}
}
#[test]
fn fallback_reasons_have_distinct_keys() {
let keys: HashSet<_> = reasons().iter().map(|r| fallback_notice(r).0).collect();
assert_eq!(keys.len(), reasons().len());
// The detail does not make another reason.
assert_eq!(
fallback_notice(&KotlinDebugUnavailable::Failed("a".into())).0,
fallback_notice(&KotlinDebugUnavailable::Failed("b".into())).0
);
}
#[test]
fn a_kotlin_lsp_config_without_a_debug_server_is_refused() {
let refused = require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}))).unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
let mut bare = launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({}));
bare.extra_json = None;
assert!(require_debug_server(&bare).is_err());
assert!(
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": null})))
.is_err()
);
for port in [serde_json::json!(4711), serde_json::json!("10.0.0.5:4711")] {
require_debug_server(&launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"debugServer": port})))
.unwrap();
}
}
#[test]
fn the_module_is_the_project_root_else_the_working_directory() {
let with_root = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({"projectRoot": "/p/app"}));
assert_eq!(debug_path(&with_root), Some(PathBuf::from("/p/app")));
assert_eq!(debug_path(&launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}))), Some(PathBuf::from("/cwd")));
let mut neither = launch(ide_dap::kotlin::ADAPTER, serde_json::json!({}));
neither.cwd = None;
assert_eq!(debug_path(&neither), None);
}
#[tokio::test(start_paused = true)]
async fn a_quick_answer_shows_no_task() {
let (sink, events) = recording_sink();
assert_eq!(wait_for_debug_server(&sink, |_| async { Ok(server()) }).await, Ok(server()));
assert!(events.lock().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_longer_wait_shows_a_task_that_ends_on_every_outcome() {
for outcome in [Ok(server()), Err(KotlinDebugUnavailable::ImportFailed)] {
let (sink, events) = recording_sink();
let answer = outcome.clone();
let result = wait_for_debug_server(&sink, |_| async move {
tokio::time::sleep(Duration::from_secs(5)).await;
answer
})
.await;
assert_eq!(result, outcome);
let tasks = tasks(&events);
assert_eq!(tasks.len(), 2, "{tasks:?}");
assert_eq!(tasks[0].0, tasks[1].0);
assert_eq!(tasks[0].1, TASK_TITLE);
assert_eq!((tasks[0].2, tasks[0].3), (false, true));
assert_eq!((tasks[1].2, tasks[1].3), (true, false));
}
}
#[tokio::test(start_paused = true)]
async fn cancelling_the_task_stops_the_wait() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |cancel| async move {
cancel.cancelled().await;
Err(KotlinDebugUnavailable::StillImporting)
})
.await
});
tokio::time::sleep(Duration::from_secs(1)).await;
let id = tasks(&events)[0].0;
assert!(ide_core::task::cancel(id));
assert_eq!(wait.await.unwrap(), Err(KotlinDebugUnavailable::StillImporting));
assert_eq!(tasks(&events).last().map(|t| t.2), Some(true));
assert!(!ide_core::task::cancel(id), "an ended wait is no longer cancellable");
}
#[tokio::test(start_paused = true)]
async fn a_dropped_wait_ends_its_task() {
let (sink, events) = recording_sink();
let wait = tokio::spawn(async move {
wait_for_debug_server(&sink, |_| std::future::pending::<Result<KotlinDebugServer, _>>()).await
});
tokio::time::sleep(Duration::from_secs(1)).await;
wait.abort();
let _ = wait.await;
assert_eq!(tasks(&events).iter().map(|t| t.2).collect::<Vec<_>>(), [false, true]);
}
#[test]
fn the_notice_shows_once_per_reason() {
let (sink, events) = recording_sink();
let notices = Mutex::new(HashSet::new());
for reason in [
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::StillImporting,
KotlinDebugUnavailable::NotStarted("needs JDK 21".into()),
KotlinDebugUnavailable::NotStarted("crashed".into()),
] {
report_fallback(&notices, &sink, &reason);
}
let notes: Vec<_> = events
.lock()
.iter()
.map(|e| match e {
IdeEvent::Notify { level, title, body } => (*level, title.clone(), body.clone()),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
notes,
[
(NotifyLevel::Info, NOTICE_TITLE.to_string(), Some(STILL_IMPORTING.to_string())),
(
NotifyLevel::Info,
NOTICE_TITLE.to_string(),
Some("kotlin-lsp did not start: needs JDK 21".to_string())
),
]
);
}
}
@@ -0,0 +1,123 @@
//! Kotlin's debugger choice through a real `Host` and project: the fallback notice and the
//! refusal of a kotlin-lsp configuration without its debug server.
use super::*;
use crate::HostConfig;
use ide_core::IdeEvent;
use ide_core::event::NotifyLevel;
use ide_lsp::KotlinDebugUnavailable;
use ide_workspace::SettingsScope;
use parking_lot::Mutex;
use std::sync::Arc;
const NOTICE_TITLE: &str = "Debugging Kotlin with kotlin-debug-adapter";
struct Fixture {
host: Host,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
_temp: tempfile::TempDir,
}
impl Fixture {
/// A trusted Gradle project opened with these global settings; kotlin-debug-adapter is a
/// missing executable, so a session fails right after the debugger is chosen.
async fn new(settings: &[(&str, serde_json::Value)]) -> Self {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("settings.gradle.kts"), "rootProject.name = \"app\"\n").unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |event| events.lock().push(event))
}),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let missing = base.join("missing").join("kotlin-debug-adapter");
host.global_settings
.set(SettingsScope::Global, "tools", serde_json::json!({"kotlin-debug-adapter": {"path": missing}}))
.unwrap();
for (key, value) in settings {
host.global_settings.set(SettingsScope::Global, key, value.clone()).unwrap();
}
host.open_project("main", &root).unwrap();
host.set_project_trust(&root, true, false).await.unwrap();
Self { host, root, events, _temp: temp }
}
fn launch(&self, adapter: &str, extra: serde_json::Value) -> LaunchConfig {
LaunchConfig {
name: "AppKt".into(),
adapter: adapter.into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(self.root.clone().into()),
env: Default::default(),
extra_json: Some(extra.to_string()),
}
}
fn kotlin_launch(&self) -> LaunchConfig {
let extra = serde_json::json!({"mainClass": "com.example.AppKt", "projectRoot": self.root});
self.launch(ide_dap::kotlin::ADAPTER, extra)
}
/// `(level, body)` of each fallback notice.
fn notices(&self) -> Vec<(NotifyLevel, Option<String>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Notify { level, title, body } if title == NOTICE_TITLE => Some((*level, body.clone())),
_ => None,
})
.collect()
}
}
/// Two Debugs, each failing only at kotlin-debug-adapter's missing executable.
async fn debug_twice(f: &Fixture) {
for _ in 0..2 {
let error = debug_start(&f.host, "main", f.kotlin_launch()).await.unwrap_err();
assert!(!error.message.contains("Restricted Mode"), "{}", error.message);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kotlin_lsp_turned_off_gives_one_notice_over_two_debugs() {
let f = Fixture::new(&[("languageServers", serde_json::json!({"kotlin-lsp": {"enabled": false}}))]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotConfigured);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_missing_kotlin_lsp_gives_one_notice_over_two_debugs() {
let missing = serde_json::json!({"kotlin-lsp": {"command": "/nonexistent/kotlin-lsp"}});
let f = Fixture::new(&[("languageServers", missing)]).await;
debug_twice(&f).await;
let (_, body) = kotlin::fallback_notice(&KotlinDebugUnavailable::NotInstalled);
assert_eq!(f.notices(), [(NotifyLevel::Info, Some(body))]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_kotlin_lsp_config_without_its_debug_server_is_refused() {
let f = Fixture::new(&[]).await;
let config = f.launch(ide_dap::kotlin_lsp::ADAPTER, serde_json::json!({"mainClass": "com.example.AppKt"}));
let refused = debug_start(&f.host, "main", config).await.unwrap_err();
assert_eq!(refused.kind, ide_core::error::ErrorKind::InvalidInput);
assert!(refused.message.contains("debugServer"), "{}", refused.message);
assert!(f.notices().is_empty());
}
+10 -2
View File
@@ -231,8 +231,16 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
// "Build before launch" is skipped when the built WAR is already newer than the
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
// sources of every module in the build, so a repeated run does not rebuild (IDEA's
// up-to-date check) but a change in a dependency module does.
let modules: Vec<std::path::PathBuf> = model
.maven
.iter()
.flat_map(|m| m.projects.iter().map(|p| p.dir.as_str()))
.chain(model.gradle.iter().flat_map(|g| g.projects.iter().map(|p| p.dir.as_str())))
.map(std::path::PathBuf::from)
.collect();
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config, &modules) {
return Ok(None);
}
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
+7
View File
@@ -896,6 +896,13 @@ impl Shared {
self.prompts_blocked.lock().remove(plugin);
}
/// A window other than `client` where `plugin` waits for the user's answer (a prompt or a
/// Commit request). The plugin runs one call at a time, so anything else asked of it waits
/// behind that answer.
pub fn waiting_elsewhere(&self, plugin: &str, client: &str) -> Option<String> {
self.prompts.lock().values().find(|p| p.plugin == plugin && p.client != client).map(|p| p.client.clone())
}
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let mut prompts = self.prompts.lock();
+11
View File
@@ -1381,6 +1381,17 @@ impl PluginHost {
invocation: Invocation,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<Option<String>> {
// Queued behind a prompt open in another window, the command would sit there with no
// sign of life until that prompt is answered: point the user at it instead.
if let Some(client) = invocation.client.as_deref()
&& let Some(waiting) = self.shared.waiting_elsewhere(id, client)
{
self.sink.emit(Some(&waiting), PluginEvent::PromptWaiting { plugin_id: id.to_owned() });
let name = self.get(id).map(|p| p.manifest().name.clone()).unwrap_or_else(|_| id.to_owned());
return Err(IdeError::invalid(format!(
"{name} is waiting for your answer in another window. Answer or close it there, then try again."
)));
}
let (cmd, arg) = (command.to_owned(), arg.map(str::to_owned));
self.code_call_cancellable(id, invocation, "running the command", cancel, move |rt, inv| {
rt.run_command(&cmd, arg.as_deref(), inv)
+36 -5
View File
@@ -121,9 +121,14 @@ pub(crate) struct NavigationIndex {
fn contains(range: Range, pos: Pos) -> bool {
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
}
/// Inside the document, start not after end. An empty range marks a position (a template file's
/// definition at its start, say): navigation lands there, but it has no text to rename.
fn valid_range(lines: &[usize], range: Range) -> bool {
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) <= (range.end.line, range.end.col)
}
fn is_empty(range: Range) -> bool {
(range.start.line, range.start.col) == (range.end.line, range.end.col)
}
impl PluginHost {
@@ -280,12 +285,20 @@ impl PluginHost {
.chain(&edges.references)
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid("invalid navigation anchors"));
return Err(IdeError::invalid(format!(
"{}: invalid navigation anchors in {}",
plugin.id(),
path.display()
)));
}
if edges.markers.len() > MAX_EDGES
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
{
return Err(IdeError::invalid("invalid navigation markers"));
return Err(IdeError::invalid(format!(
"{}: invalid navigation markers in {}",
plugin.id(),
path.display()
)));
}
let mut index = self.navigation.lock();
if index.epochs.get(root).copied() != generation {
@@ -406,7 +419,7 @@ impl PluginHost {
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in &file.edges.definitions {
for candidate in file.edges.definitions.iter().filter(|a| !is_empty(a.range)) {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
@@ -414,7 +427,7 @@ impl PluginHost {
}
}
let mut matches = HashMap::new();
for candidate in &file.edges.references {
for candidate in file.edges.references.iter().filter(|a| !is_empty(a.range)) {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
@@ -580,3 +593,21 @@ impl ExtensionPoint for IndexersPoint {
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_ranges_inside_the_document_are_valid_positions() {
let lines = [3, 0];
let range = |a: (u32, u32), b: (u32, u32)| Range::new(Pos::new(a.0, a.1), Pos::new(b.0, b.1));
assert!(valid_range(&lines, range((0, 0), (0, 0))));
assert!(valid_range(&lines, range((1, 0), (1, 0))));
assert!(valid_range(&lines, range((0, 1), (0, 3))));
assert!(!valid_range(&lines, range((0, 2), (0, 1))), "reversed");
assert!(!valid_range(&lines, range((0, 4), (0, 4))), "past the line end");
assert!(!valid_range(&lines, range((2, 0), (2, 0))), "past the last line");
assert!(is_empty(range((0, 0), (0, 0))) && !is_empty(range((0, 0), (0, 1))));
}
}
@@ -165,6 +165,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::PromptWaiting { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
+4
View File
@@ -321,6 +321,10 @@ pub enum PluginEvent {
/// The call that asked gave up (cancelled, or its plugin stopped): close the prompt.
#[serde(rename_all = "camelCase")]
PromptClosed { prompt_id: String },
/// A command of this window's prompting plugin was run from another window and refused
/// while the plugin waits for the answer here: bring this window to the front.
#[serde(rename_all = "camelCase")]
PromptWaiting { plugin_id: String },
/// Bring the tool window of the plugin's view `view` to the front.
#[serde(rename_all = "camelCase")]
RevealView { plugin_id: String, view: String },
+11 -1
View File
@@ -13,7 +13,7 @@ use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -50,6 +50,8 @@ pub struct Project {
pub cache_dir: PathBuf,
/// Superseded launch snapshots retained until this project session ends.
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
/// Reasons this window was already told why Kotlin is debugged with kotlin-debug-adapter.
pub(crate) kotlin_debug_notices: Mutex<HashSet<&'static str>>,
/// Database tool window: data sources and their connections.
pub db: Arc<ide_db::DbManager>,
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
@@ -563,6 +565,7 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
@@ -596,6 +599,12 @@ impl Host {
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
}));
}
{
let weak = Arc::downgrade(&project);
project.agents.set_security_hook(Arc::new(move || {
weak.upgrade().map(|p| p.settings.effective().agent_security).unwrap_or_default()
}));
}
self.insert_project(window, project.clone());
project.lsp.prewarm_java_configuration();
if let Some(bridge) = &project.kotlin_bridge {
@@ -718,6 +727,7 @@ impl Host {
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
kotlin_debug_notices: Mutex::default(),
db,
http_history,
agents,
+29
View File
@@ -4157,6 +4157,35 @@ fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget(
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
}
#[test]
fn a_command_from_another_window_is_refused_while_the_plugin_waits_for_an_answer() {
let (f, doc) = interactive_actions_fixture();
let action = f
.host
.provide_code_actions(&doc, ide_core::Range::default())
.into_iter()
.find(|(_, action)| action.data.as_deref() == Some("pick"))
.unwrap()
.1;
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
// Queued behind the open prompt it would not run until the prompt is answered.
let other = Invocation { client: Some("other-window".into()), ..Default::default() };
let err = f.host.run_command("misbehave", "anything", None, other).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput);
assert!(err.message.contains("waiting for your answer in another window"), "{}", err.message);
let events = f.take_events();
assert!(
events.iter().any(|(client, e)| client.as_deref() == Some("editor-window")
&& matches!(e, PluginEvent::PromptWaiting { plugin_id } if plugin_id == "misbehave")),
"the prompting window is brought forward: {events:?}"
);
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
running.join().unwrap().unwrap();
});
}
#[test]
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
for (how, kind) in [
+121
View File
@@ -105,6 +105,89 @@ pub fn environment_file(file: &Path, root: Option<&Path>, private: bool) -> IdeR
Ok(path)
}
/// Where `{{name}}` is defined in the environment files that apply to `file`: the key `name`
/// in each environment (and `$shared`) of the nearest private file, then of the nearest public
/// file — the lookup order. Positions are 0-based lines and UTF-16 columns of the key, quotes
/// included.
pub fn variable_definitions(file: &Path, root: Option<&Path>, name: &str) -> Vec<ide_core::Location> {
let dirs = search_dirs(Some(file), root);
let mut out = Vec::new();
for file_name in [PRIVATE_FILE, PUBLIC_FILE] {
let Some(path) = dirs.iter().map(|d| d.join(file_name)).find(|p| p.is_file()) else { continue };
let Ok(text) = ide_vfs::load_text(&path) else { continue };
for range in environment_keys(&text.text, name) {
out.push(ide_core::Location { path: path.clone().into(), range, uri: None });
}
}
out
}
/// The ranges of keys equal to `name` one level inside the top-level object (an
/// environment's variables), found by scanning the JSON text without building a value.
fn environment_keys(text: &str, name: &str) -> Vec<ide_core::Range> {
use ide_core::{Pos, Range};
let mut found = Vec::new();
let (mut line, mut col) = (0u32, 0u32);
let mut depth = 0usize;
let mut chars = text.char_indices().peekable();
// Whether the last string closed at depth 2 may be a key: set after `{` or `,`.
let mut expect_key = false;
while let Some((_, c)) = chars.next() {
match c {
'"' => {
let start = Pos::new(line, col);
col += 1;
let mut value = String::new();
let mut escaped = false;
for (_, c) in chars.by_ref() {
col += c.len_utf16() as u32;
if escaped {
value.push(match c {
'n' => '\n',
't' => '\t',
other => other,
});
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
break;
} else {
value.push(c);
}
}
let end = Pos::new(line, col);
if depth == 2 && expect_key && value == name {
let mut rest = chars.clone();
while rest.peek().is_some_and(|(_, c)| c.is_whitespace()) {
rest.next();
}
if rest.peek().is_some_and(|(_, c)| *c == ':') {
found.push(Range { start, end });
}
}
expect_key = false;
continue;
}
'{' | '[' => {
depth += 1;
expect_key = c == '{';
}
'}' | ']' => depth = depth.saturating_sub(1),
',' => expect_key = true,
':' => expect_key = false,
'\n' => {
line += 1;
col = 0;
continue;
}
_ => {}
}
col += c.len_utf16() as u32;
}
found
}
impl Environments {
pub fn load(file: Option<&Path>, root: Option<&Path>) -> IdeResult<Self> {
let dirs = search_dirs(file, root);
@@ -221,6 +304,44 @@ impl Resolver<'_> {
mod tests {
use super::*;
#[test]
fn finds_variable_keys_in_every_environment_private_first() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("api")).unwrap();
std::fs::write(
root.join(PUBLIC_FILE),
"{\n \"$shared\": { \"host\": \"shared\" },\n \"dev\": {\n \"name\": \"host\",\n \"host\": \"localhost\",\n \"ssl\": { \"host\": \"nested\" }\n },\n \"prod\": { \"hostname\": \"x\", \"host\": \"example.com\" }\n}\n",
)
.unwrap();
std::fs::write(root.join("api").join(PRIVATE_FILE), "{ \"dev\": { \"😀\": 1, \"host\": \"secret\" } }")
.unwrap();
let file = root.join("api").join("requests.http");
let found = variable_definitions(&file, Some(root), "host");
let at: Vec<_> = found
.iter()
.map(|l| {
(
l.path.0.file_name().unwrap().to_string_lossy().into_owned(),
l.range.start.line,
l.range.start.col,
l.range.end.col,
)
})
.collect();
assert_eq!(
at,
vec![
(PRIVATE_FILE.to_owned(), 0, 20, 26),
(PUBLIC_FILE.to_owned(), 1, 15, 21),
(PUBLIC_FILE.to_owned(), 4, 4, 10),
(PUBLIC_FILE.to_owned(), 7, 29, 35),
],
"values and nested objects are not definitions"
);
assert!(variable_definitions(&file, Some(root), "missing").is_empty());
}
#[test]
fn merges_private_public_and_shared() {
let envs = Environments::from_json(
+3
View File
@@ -398,6 +398,9 @@ impl Handler for ClientHandler {
}
}
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
"intellij/workspaceImportState" if self.server.name == crate::kotlin_lsp::KOTLIN_LSP => {
crate::kotlin_lsp::record_import_state(&self.server, self.generation, &params)
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
mgr.java_import_ready(self.server.clone(), self.generation);
}
+195
View File
@@ -0,0 +1,195 @@
//! kotlin-lsp's built-in debug adapter: `start_debug_server` once the server has imported
//! the project (breakpoints set before that are "verified" but never hit).
use crate::LspManager;
use crate::server::Server;
use ide_core::event::ServerStatus;
use ide_core::{CancellationToken, LanguageId};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// The server's name in `languages/kotlin.toml`.
pub const KOTLIN_LSP: &str = "kotlin-lsp";
/// A cold start imports the Gradle model before answering `initialize`.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
const COMMAND_TIMEOUT: Duration = Duration::from_secs(120);
/// The last `intellij/workspaceImportState` of the current instance.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ImportState {
pub finished: bool,
/// Workspace folders whose import did not succeed.
pub failed: Vec<PathBuf>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct KotlinDebugServer {
/// Local TCP port of the DAP server.
pub port: u16,
/// Root of the kotlin-lsp instance that serves it.
pub root: PathBuf,
}
/// Why kotlin-lsp cannot debug the module, for the fallback notice.
#[derive(Clone, Debug, PartialEq)]
pub enum KotlinDebugUnavailable {
/// No `kotlin-lsp` among the module's servers (disabled in settings, another server chosen).
NotConfigured,
NotInstalled,
/// The status message, else the status.
NotStarted(String),
/// The import did not finish in time, or the wait was cancelled.
StillImporting,
ImportFailed,
Failed(String),
}
/// Records `intellij/workspaceImportState` from instance `generation`. Completion is this
/// notification, not "no `$/progress` left": kotlin-lsp reports "Indexing" every few seconds.
pub(crate) fn record_import_state(server: &Server, generation: u64, params: &Value) {
let failed = params["folders"]
.as_array()
.into_iter()
.flatten()
.filter(|folder| folder["status"] != "SUCCESS")
.filter_map(|folder| folder["folderUri"].as_str().and_then(crate::uri::uri_to_path))
.collect();
server.set_import_state(generation, ImportState { finished: params["phase"] == "FINISHED", failed });
}
fn importing(server: &Server) -> bool {
server.progress.lock().values().any(|p| p.title.contains("Importing"))
}
impl LspManager {
/// Starts kotlin-lsp's debug server for `path` (a module directory or source file) once
/// the project is imported, starting kotlin-lsp if needed. `import_wait` bounds the wait
/// for the import after the server is up; cancelling `cancel` ends it as
/// [`KotlinDebugUnavailable::StillImporting`].
pub async fn kotlin_debug_server(
&self,
path: &Path,
import_wait: Duration,
cancel: &CancellationToken,
) -> Result<KotlinDebugServer, KotlinDebugUnavailable> {
let server = self
.servers_for(&LanguageId::new("kotlin"), path)
.into_iter()
.find(|s| s.name == KOTLIN_LSP)
.ok_or(KotlinDebugUnavailable::NotConfigured)?;
self.ensure_started(&server);
let ready = tokio::select! {
biased;
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
ready = server.wait_ready(READY_TIMEOUT) => ready,
};
if ready.is_none() {
let status = server.status();
return Err(match status {
ServerStatus::Missing => KotlinDebugUnavailable::NotInstalled,
ServerStatus::Misconfigured | ServerStatus::Crashed => {
KotlinDebugUnavailable::NotStarted(server.status_message().unwrap_or_else(|| format!("{status:?}")))
}
_ => KotlinDebugUnavailable::NotStarted(format!("{status:?}")),
});
}
tokio::select! {
biased;
() = cancel.cancelled() => return Err(KotlinDebugUnavailable::StillImporting),
imported = tokio::time::timeout(import_wait, wait_for_import(&server, path)) => {
imported.unwrap_or(Err(KotlinDebugUnavailable::StillImporting))?
}
}
// The instance that finished importing, also when it replaced the one waited for.
let Some(run) = server.running() else {
return Err(KotlinDebugUnavailable::NotStarted(format!("{:?}", server.status())));
};
let params =
json!({ "command": "start_debug_server", "arguments": [crate::uri::path_to_uri_string(&server.root)] });
let request = run.peer.request::<Value, Value>("workspace/executeCommand", params, Some(cancel.clone()));
let port = match tokio::time::timeout(COMMAND_TIMEOUT, request).await {
Ok(Ok(port)) => port,
Ok(Err(e)) => return Err(KotlinDebugUnavailable::Failed(e.to_string())),
Err(_) => {
return Err(KotlinDebugUnavailable::Failed(format!(
"start_debug_server timed out after {} s",
COMMAND_TIMEOUT.as_secs()
)));
}
};
match port.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) {
Some(port) => Ok(KotlinDebugServer { port, root: server.root.clone() }),
None => Err(KotlinDebugUnavailable::Failed(format!("unexpected debug server port: {port}"))),
}
}
}
/// Waits until the current instance reports its import finished and its "Importing" task
/// ended, which arrive in either order.
async fn wait_for_import(server: &Server, path: &Path) -> Result<(), KotlinDebugUnavailable> {
let mut state = server.import_state.subscribe();
loop {
// Created before the check so a progress end in between still wakes us.
let progress_ended = server.phase_notified();
let current = state.borrow_and_update().clone();
if let Some(current) = current
&& current.finished
&& !importing(server)
{
if current.failed.iter().any(|dir| canonical(path).starts_with(canonical(dir))) {
return Err(KotlinDebugUnavailable::ImportFailed);
}
return Ok(());
}
tokio::select! {
_ = state.changed() => {}
() = progress_ended => {}
}
}
}
/// `path` with its deepest existing ancestor resolved, so a symlinked root (or `/tmp` vs
/// `/private/tmp` on macOS) and the folder URIs kotlin-lsp reports name one directory alike.
fn canonical(path: &Path) -> PathBuf {
for ancestor in path.ancestors() {
if let Ok(real) = dunce::canonicalize(ancestor) {
let rest = path.strip_prefix(ancestor).unwrap_or(Path::new(""));
return if rest.as_os_str().is_empty() { real } else { real.join(rest) };
}
}
path.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{IdeEvent, SharedSink};
use std::sync::Arc;
#[test]
fn an_instance_that_was_replaced_does_not_record_its_import() {
let cfg = toml::from_str("name = \"kotlin-lsp\"\ncommand = \"kotlin-lsp\"").unwrap();
let server = Server::new(cfg, PathBuf::from("/p"), LanguageId::new("kotlin"));
let finished = json!({ "phase": "FINISHED", "folders": [
{ "folderUri": "file:///p/", "status": "SUCCESS" },
{ "folderUri": "file:///p/lib/", "status": "FAILED" },
]});
let old = server.begin_start().unwrap();
record_import_state(&server, old, &json!({ "phase": "IN_PROGRESS", "folders": [] }));
assert_eq!(*server.import_state.borrow(), Some(ImportState { finished: false, failed: vec![] }));
record_import_state(&server, old, &finished);
let done = Some(ImportState { finished: true, failed: vec![PathBuf::from("/p/lib")] });
assert_eq!(*server.import_state.borrow(), done);
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
server.begin_stop(&sink, false);
let new = server.begin_start().unwrap();
assert_eq!(*server.import_state.borrow(), None);
// The dying instance's FINISHED arrives after the restart.
record_import_state(&server, old, &finished);
assert_eq!(*server.import_state.borrow(), None);
record_import_state(&server, new, &finished);
assert_eq!(*server.import_state.borrow(), done);
}
}
+25 -2
View File
@@ -20,6 +20,7 @@ mod handler;
mod injections;
mod inspect;
pub mod jdtls;
mod kotlin_lsp;
mod launch;
mod library_source;
mod loading;
@@ -38,6 +39,7 @@ mod vue_navigation;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use jdtls::jvm_inspect::JdtJvmInspector;
pub use kotlin_lsp::{KotlinDebugServer, KotlinDebugUnavailable};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
@@ -317,6 +319,10 @@ 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;
@@ -2374,8 +2380,25 @@ impl LspManager {
let query = || async {
let mut params = snap.pos_params(t, pos);
params["newName"] = json!(new_name);
let r: Option<lsp::WorkspaceEdit> =
self.call(t, "textDocument/rename", params, Some(cancel), LONG_TIMEOUT).await?;
// A server still analysing an edit withdraws the rename (`ContentModified`, which
// arrives as a cancellation). The user asked for it: ask again shortly instead of
// dropping it without a word, and say why if it keeps being withdrawn.
let mut attempt = 0;
let r: Option<lsp::WorkspaceEdit> = loop {
match self.call(t, "textDocument/rename", params.clone(), Some(cancel), LONG_TIMEOUT).await {
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled && !cancel.is_cancelled() => {
attempt += 1;
if attempt >= RENAME_ATTEMPTS {
return Err(IdeError::invalid(format!(
"{} was still analysing a change and withdrew the rename; try again",
t.server.name
)));
}
tokio::time::sleep(RENAME_RETRY_DELAY).await;
}
r => break r?,
}
};
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
};
// The Angular server renames in the file's own project only, whatever is open.
+13
View File
@@ -148,6 +148,8 @@ pub struct Server {
/// The instance whose just registered external project is loading, until an open file's
/// diagnostics arrive (0: none).
pub external_loading: std::sync::atomic::AtomicU64,
/// kotlin-lsp's project import as the current instance last reported it.
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
}
impl Server {
@@ -193,6 +195,7 @@ impl Server {
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
external_loading: std::sync::atomic::AtomicU64::new(0),
import_state: watch::channel(None).0,
}
}
@@ -249,10 +252,20 @@ impl Server {
*self.started.lock() = Some(Instant::now());
// A crash report quotes only this instance's output, not its predecessors'.
self.stderr.lock().clear();
self.import_state.send_replace(None);
self.phase.send_replace(Phase::Starting);
Some(st.generation)
}
/// Publishes instance `generation`'s import state unless a newer start replaced it; checked
/// under the state lock so it cannot land after [`Self::begin_start`]'s reset.
pub(crate) fn set_import_state(&self, generation: u64, state: crate::kotlin_lsp::ImportState) {
let st = self.state.lock();
if st.generation == generation {
self.import_state.send_replace(Some(state));
}
}
/// Starting → Missing/Failed, publishing the status the failed start reports
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
/// to say why it has no server, and must not still see `Starting`.
+296 -1
View File
@@ -7,7 +7,7 @@ use ide_core::{
use ide_jsonrpc::{Handler, NoopHandler, Peer};
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
use ide_lsp::types::*;
use ide_lsp::{LaunchError, Launcher, LspManager};
use ide_lsp::{KotlinDebugServer, KotlinDebugUnavailable, LaunchError, Launcher, LspManager};
use ide_text::AppliedChange;
use parking_lot::Mutex;
use serde_json::{Value, json};
@@ -3211,6 +3211,33 @@ async fn rename_merges_the_servers_that_can_rename_there() {
assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_rename_the_server_withdraws_while_analysing_is_asked_again_then_explained() {
let h = harness(&[("dart", incremental_caps())]);
let (doc, path) = h.open("a.mk", "count = 1\n");
h.wait_status("dart", ServerStatus::Ready).await;
let dart = h.launcher.state("dart");
let r = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":5}});
dart.respond("textDocument/rename", json!({ "changes": { uri_of(&path): [{ "range": r, "newText": "total" }] } }));
// Withdrawn while the server analyses an edit, then answered.
dart.errors.lock().insert("textDocument/rename".into(), (-32801, "Document was modified".into()));
let renaming = {
let mgr = h.mgr.clone();
tokio::spawn(async move { mgr.rename(doc, Pos::new(0, 1), "total".into(), Default::default()).await })
};
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
dart.errors.lock().clear();
let edit = renaming.await.unwrap().unwrap().edit;
assert_eq!(edits_in(&edit, &path), [(rng(0, 0, 0, 5), "total".into())]);
assert!(dart.requested("textDocument/rename").len() >= 2);
// Withdrawn every time: an error that says so, not a silent cancellation.
dart.errors.lock().insert("textDocument/rename".into(), (-32801, "Document was modified".into()));
let err = h.mgr.rename(doc, Pos::new(0, 1), "total".into(), Default::default()).await.unwrap_err();
assert_ne!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(err.message.contains("try again"), "{}", err.message);
}
#[tokio::test(flavor = "multi_thread")]
async fn angular_server_is_asked_about_typescript_only_in_component_templates() {
let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())];
@@ -5963,6 +5990,208 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
assert_eq!(e.message, "plain doesn't support Pull Members Up");
}
// ------------------------------------------------------------------ kotlin-lsp debug server
type KotlinDebug = Result<KotlinDebugServer, KotlinDebugUnavailable>;
fn kotlin_lsp_harness() -> (Harness, Arc<MockState>) {
let h = harness_for("kotlin", "kt", &[("kotlin-lsp", incremental_caps())], &[]);
let s = h.launcher.state("kotlin-lsp");
s.respond("workspace/executeCommand", json!(4711));
(h, s)
}
fn spawn_kotlin_debug_server(
h: &Harness,
path: &Path,
import_wait: Duration,
cancel: &CancellationToken,
) -> tokio::task::JoinHandle<KotlinDebug> {
let (mgr, path, cancel) = (h.mgr.clone(), path.to_owned(), cancel.clone());
tokio::spawn(async move { mgr.kotlin_debug_server(&path, import_wait, &cancel).await })
}
async fn joined(debug: tokio::task::JoinHandle<KotlinDebug>) -> KotlinDebug {
tokio::time::timeout(Duration::from_secs(10), debug).await.expect("kotlin_debug_server returned").unwrap()
}
fn import_progress(s: &MockState, value: Value) {
s.peer().notify("$/progress", json!({ "token": "import", "value": value })).unwrap();
}
fn import_finished(s: &MockState, folders: &[(&Path, &str)]) {
let folders: Vec<Value> = folders
.iter()
.map(|(dir, status)| json!({ "folderUri": format!("{}/", uri_of(dir)), "tool": "gradle", "status": status }))
.collect();
s.peer().notify("intellij/workspaceImportState", json!({ "phase": "FINISHED", "folders": folders })).unwrap();
}
fn debug_server_requests(s: &MockState) -> Vec<Value> {
s.requested("workspace/executeCommand")
}
#[tokio::test(flavor = "multi_thread")]
async fn waits_for_the_import_before_starting_the_debug_server() {
// kotlin-lsp sends the progress end and FINISHED within the same millisecond, in either order.
for finished_first in [false, true] {
let (h, s) = kotlin_lsp_harness();
let main = h.root.join("Main.kt");
std::fs::write(&main, "fun main() {}").unwrap();
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
// Repeats every few seconds while the project stays usable: never waited for.
s.peer()
.notify("$/progress", json!({ "token": "index", "value": { "kind": "begin", "title": "Indexing" } }))
.unwrap();
import_progress(&s, json!({ "kind": "begin", "title": "Importing" }));
h.wait_status("kotlin-lsp", ServerStatus::Busy).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(debug_server_requests(&s).is_empty(), "started while importing");
if finished_first {
import_finished(&s, &[(&h.root, "SUCCESS")]);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(debug_server_requests(&s).is_empty(), "started while the Importing task runs");
import_progress(&s, json!({ "kind": "end" }));
} else {
import_progress(&s, json!({ "kind": "end" }));
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(debug_server_requests(&s).is_empty(), "started before FINISHED");
import_finished(&s, &[(&h.root, "SUCCESS")]);
}
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
assert_eq!(
debug_server_requests(&s),
vec![json!({ "command": "start_debug_server", "arguments": [uri_of(&h.root)] })],
"finished_first: {finished_first}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn still_importing_after_the_wait_is_reported() {
let (h, s) = kotlin_lsp_harness();
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(200), &CancellationToken::new()).await;
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
assert!(debug_server_requests(&s).is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_the_import_wait_reports_still_importing() {
let (h, s) = kotlin_lsp_harness();
let cancel = CancellationToken::new();
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(60), &cancel);
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!debug.is_finished());
cancel.cancel();
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::StillImporting));
assert!(debug_server_requests(&s).is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_import_of_the_module_is_reported() {
// Another module that failed does not keep this one from debugging.
for other_module_failed in [false, true] {
let (h, s) = kotlin_lsp_harness();
let main = h.root.join("app/Main.kt");
let debug = spawn_kotlin_debug_server(&h, &main, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
let lib = h.root.join("lib");
let (folders, expected) = if other_module_failed {
(
vec![(h.root.as_path(), "SUCCESS"), (lib.as_path(), "FAILED")],
Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }),
)
} else {
(vec![(h.root.as_path(), "FAILED")], Err(KotlinDebugUnavailable::ImportFailed))
};
import_finished(&s, &folders);
assert_eq!(joined(debug).await, expected, "other_module_failed: {other_module_failed}");
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_import_is_reported_through_a_symlinked_root() {
// The debugged path names the root through a symlink, the folder URI names its target,
// and the file does not exist yet.
let (h, s) = kotlin_lsp_harness();
let links = tempfile::tempdir().unwrap();
let link = links.path().join("project");
std::os::unix::fs::symlink(&h.root, &link).unwrap();
let debug =
spawn_kotlin_debug_server(&h, &link.join("app/Main.kt"), Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
import_finished(&s, &[(&h.root, "FAILED")]);
assert_eq!(joined(debug).await, Err(KotlinDebugUnavailable::ImportFailed));
}
#[tokio::test(flavor = "multi_thread")]
async fn an_older_finished_import_does_not_count_after_a_restart() {
let (h, s) = kotlin_lsp_harness();
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
import_finished(&s, &[(&h.root, "SUCCESS")]);
assert!(joined(debug).await.is_ok());
let id = h.mgr.servers()[0].id;
h.mgr.restart(id).await.unwrap();
let r = h.mgr.kotlin_debug_server(&h.root, Duration::from_millis(300), &CancellationToken::new()).await;
assert_eq!(r, Err(KotlinDebugUnavailable::StillImporting));
assert_eq!(debug_server_requests(&s).len(), 1);
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
import_finished(&s, &[(&h.root, "SUCCESS")]);
assert_eq!(joined(debug).await, Ok(KotlinDebugServer { port: 4711, root: h.root.clone() }));
assert_eq!(debug_server_requests(&s).len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn without_kotlin_lsp_the_reason_is_not_configured() {
let wait = Duration::from_secs(1);
let cancel = CancellationToken::new();
let h = harness_for("kotlin", "kt", &[("kotlin-language-server", incremental_caps())], &[]);
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotConfigured));
let (h, s) = kotlin_lsp_harness();
s.missing.store(true, Ordering::SeqCst);
assert_eq!(h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await, Err(KotlinDebugUnavailable::NotInstalled));
let (h, _) = kotlin_lsp_harness();
h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into())));
assert_eq!(
h.mgr.kotlin_debug_server(&h.root, wait, &cancel).await,
Err(KotlinDebugUnavailable::NotStarted("needs JDK 21".into()))
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_debug_server_error_is_reported() {
let (h, s) = kotlin_lsp_harness();
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "boom".into()));
let debug = spawn_kotlin_debug_server(&h, &h.root, Duration::from_secs(10), &CancellationToken::new());
h.wait_status("kotlin-lsp", ServerStatus::Ready).await;
import_finished(&s, &[(&h.root, "SUCCESS")]);
match joined(debug).await {
Err(KotlinDebugUnavailable::Failed(m)) => assert!(m.contains("boom"), "{m}"),
other => panic!("{other:?}"),
}
s.errors.lock().clear();
for port in [json!(0), json!(65536), json!("4711"), Value::Null] {
s.respond("workspace/executeCommand", port.clone());
match h.mgr.kotlin_debug_server(&h.root, Duration::from_secs(1), &CancellationToken::new()).await {
Err(KotlinDebugUnavailable::Failed(m)) => {
assert_eq!(m, format!("unexpected debug server port: {port}"))
}
other => panic!("{port}: {other:?}"),
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
for encoding in ["utf-16", "utf-8"] {
@@ -6151,3 +6380,69 @@ async fn missing_injected_server_uses_the_normal_single_install_notification() {
h.mgr.did_close(host);
h.mgr.shutdown_all().await;
}
/// #2248: `go.mod` files are served by gopls with the language id it
/// understands, and its pushed diagnostics reach the Problems view.
#[tokio::test(flavor = "multi_thread")]
async fn gomod_files_are_served_by_gopls() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let registry = Arc::new(LanguageRegistry::load(None).unwrap());
// The bundled configs route Go files of every kind to gopls.
for lang in ["go", "go.mod", "go.work"] {
let cfg = registry.get(&LanguageId::new(lang)).unwrap_or_else(|| panic!("{lang} has no language definition"));
assert!(cfg.language_servers.iter().any(|s| s.name == "gopls"), "{lang} is not served by gopls");
}
let launcher = Arc::new(MockLauncher::default());
*launcher.state("gopls").caps.lock() = incremental_caps();
let events = Arc::new(Mutex::new(Vec::new()));
let sink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let mgr = LspManager::with_launcher(root.clone(), registry, sink, launcher.clone());
let text = "module example.com/app\n\ngo 1.22\n";
let path = root.join("go.mod");
std::fs::write(&path, text).unwrap();
assert!(mgr.serves_path(&path));
mgr.did_open(DocId::next(), &path, &LanguageId::new("go.mod"), 1, text);
let gopls = launcher.state("gopls");
eventually(|| !gopls.notified("textDocument/didOpen").is_empty()).await;
// gopls only treats the file as a module file for the `go.mod` id.
let opened = &gopls.notified("textDocument/didOpen")[0]["textDocument"];
assert_eq!(opened["languageId"], json!("go.mod"));
// The module root (the go.mod itself is the marker) scopes the server.
let init = &gopls.requested("initialize")[0];
assert_eq!(init["rootUri"], json!(uri_of(&root)));
// Same startup options as when a `.go` file launches the server first.
assert_eq!(init["initializationOptions"]["semanticTokens"], json!(true));
// Pushed go.mod diagnostics surface with gopls as their source.
gopls
.peer()
.notify(
"textDocument/publishDiagnostics",
json!({ "uri": uri_of(&path), "diagnostics": [{
"range": {"start": {"line": 2, "character": 3}, "end": {"line": 2, "character": 7}},
"severity": 1, "message": "missing requirement", "source": "gopls" }] }),
)
.unwrap();
eventually(|| {
events
.lock()
.iter()
.any(|e| matches!(e, IdeEvent::Diagnostics { path: p, source, .. } if p.0 == path && source == "gopls"))
})
.await;
let items = events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Diagnostics { path: p, items, .. } if p.0 == path => Some(items.clone()),
_ => None,
})
.next_back()
.unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].message, "missing requirement");
mgr.shutdown_all().await;
}
+179 -8
View File
@@ -863,6 +863,48 @@ fn module_dir(root: &Path, module: Option<&Path>) -> PathBuf {
}
}
/// The module directory a Tomcat config's WAR belongs to. Created configs carry it as `cwd`
/// (set from the selected artifact); when it is missing, the selected artifact id still names
/// the module — `maven:<pom>` its POM, `gradle:…` its build — instead of falling back to the
/// project root, whose own `src` rarely exists and would make every launch look current.
fn artifact_module_dir(root: &Path, config: &RunConfig) -> PathBuf {
if config.cwd.is_some() {
return module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
}
let selected = config.extras.params.get("artifact").filter(|s| !s.is_empty());
if let Some(id) = selected {
if let Some(pom) = id.strip_prefix("maven:") {
// `maven:pom.xml` is the root module itself.
return match Path::new(pom).parent().filter(|p| !p.as_os_str().is_empty()) {
Some(dir) => root.join(dir),
None => root.to_path_buf(),
};
}
if let Some(rest) = id.strip_prefix("gradle:") {
// `gradle:<project path>` for the root build, else `gradle:["<build dir>", "<path>"]`.
if let Ok((dir, path)) = serde_json::from_str::<(String, String)>(rest) {
return root.join(gradle_module_dir(&dir, &path));
}
if !rest.trim_matches(':').is_empty() {
return root.join(gradle_module_dir("", rest));
}
}
}
root.to_path_buf()
}
/// Maps a Gradle project path (`:` for the build itself, `:web` for a subproject) below its
/// build directory. Custom `projectDir` mappings are invisible here, so this is the
/// conventional layout; still closer than the project root.
fn gradle_module_dir(dir: &str, path: &str) -> PathBuf {
let mut module = PathBuf::from(dir);
let rel = path.trim_start_matches(':').replace(':', "/");
if !rel.is_empty() {
module.push(rel);
}
module
}
/// The newest modification time of `path` or, for a directory, anything below it.
fn newest_mtime_under(path: &Path) -> Option<std::time::SystemTime> {
let meta = fs::metadata(path).ok()?;
@@ -902,14 +944,16 @@ fn find_built_war(root: &Path, module: Option<&Path>) -> Option<PathBuf> {
/// Whether the WAR a Tomcat config deploys is newer than the module's sources, so its
/// "Before launch › Build" task can be skipped (IDEA's up-to-date check). Conservative:
/// a missing WAR or a newer source/build file means it is not current. Only the module's
/// own sources and build files are inspected, not dependency modules.
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool {
/// a missing WAR or a newer source/build file means it is not current. Besides the WAR
/// module's sources, web resources and build files, the sources and build files of every
/// module in `modules` (project-relative build modules) are inspected: a mapper XML or
/// class in a module the WAR depends on is packaged into it as a JAR.
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig, modules: &[PathBuf]) -> bool {
let p = &config.extras.params;
if p.get("deployment").map(String::as_str).unwrap_or("artifact") != "artifact" {
return true;
}
let module = module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
let module = artifact_module_dir(root, config);
let selected = p.get("artifact").filter(|s| !s.is_empty());
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
@@ -923,7 +967,24 @@ pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool
};
let Ok(war_time) = fs::metadata(&war).and_then(|m| m.modified()) else { return false };
let mut newest = newest_mtime_under(&module.join("src"));
for dir in [module.as_path(), root] {
// Web content packaged from outside `src` (a custom `warSourceDirectory`, `webResources`
// directories, a custom Gradle web app dir) also lands in the WAR — the running-session
// sync copies it from these same folders — so a save there must rebuild too (#2685).
for (dir, _) in web_sources(&module) {
if let Some(time) = newest_mtime_under(&dir)
&& newest.is_none_or(|cur| time > cur)
{
newest = Some(time);
}
}
let other_modules = modules.iter().map(|m| root.join(m));
for dir in [module.clone(), root.to_path_buf()].into_iter().chain(other_modules) {
if dir != module
&& let Some(time) = newest_mtime_under(&dir.join("src"))
&& newest.is_none_or(|cur| time > cur)
{
newest = Some(time);
}
for name in ["pom.xml", "build.gradle", "build.gradle.kts"] {
if let Ok(time) = fs::metadata(dir.join(name)).and_then(|m| m.modified())
&& newest.is_none_or(|cur| time > cur)
@@ -946,6 +1007,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim()));
let module = artifact_module_dir(root, config);
let war = match recorded.filter(|_| id_matches) {
Some(war) => {
if war.is_file() && is_war(&war) {
@@ -954,7 +1016,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
}
}
None => find_built_war(root, config.cwd.as_ref().map(|c| c.0.as_path())).ok_or_else(|| {
None => find_built_war(root, Some(module.as_path())).ok_or_else(|| {
invalid("No built WAR artifact found - build the project first, or add 'Build' to Before launch")
})?,
};
@@ -1592,10 +1654,119 @@ mod tests {
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
// The WAR was packed after the source was written.
assert!(artifact_is_current(root, &base, &c));
assert!(artifact_is_current(root, &base, &c, &[]));
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&source, "class App { int changed; }").unwrap();
assert!(!artifact_is_current(root, &base, &c));
assert!(!artifact_is_current(root, &base, &c, &[]));
}
#[test]
fn up_to_date_check_follows_the_selected_artifact_when_cwd_is_missing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let module = root.join("web");
fs::create_dir_all(module.join("src/main/resources")).unwrap();
fs::create_dir_all(module.join("target")).unwrap();
fs::write(root.join("pom.xml"), "<project/>").unwrap();
fs::write(module.join("pom.xml"), "<project/>").unwrap();
let source = module.join("src/main/resources/applicationContext.xml");
fs::write(&source, "<beans/>").unwrap();
let war = module.join("target/app.war");
archive(&war, &[("WEB-INF/web.xml", "<web-app/>")]);
let base = root.join("cache");
fs::create_dir_all(&base).unwrap();
// A previous IDE build recorded its archive, as `ide_build::web::build` leaves behind.
fs::write(base.join("artifact-id.txt"), "maven:web/pom.xml").unwrap();
fs::write(base.join("artifact-path.txt"), war.to_string_lossy().as_bytes()).unwrap();
// Older configs may not carry the artifact's module as `cwd` (the UI sets it today).
let mut c = config();
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
assert!(artifact_is_current(root, &base, &c, &[]));
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&source, "<beans><edited/></beans>").unwrap();
assert!(!artifact_is_current(root, &base, &c, &[]));
}
#[test]
fn up_to_date_check_watches_web_resources_outside_src() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let module = root.join("web");
fs::create_dir_all(module.join("src/main/java")).unwrap();
fs::create_dir_all(module.join("target")).unwrap();
fs::create_dir_all(module.join("WebContent/WEB-INF")).unwrap();
fs::write(
module.join("pom.xml"),
"<project><plugin><artifactId>maven-war-plugin</artifactId><configuration><webResources>\
<resource><directory>WebContent</directory></resource></webResources></configuration></plugin></project>",
)
.unwrap();
fs::write(module.join("src/main/java/App.java"), "class App {}").unwrap();
let web_xml = module.join("WebContent/WEB-INF/web.xml");
fs::write(&web_xml, "<web-app/>").unwrap();
let war = module.join("target/app.war");
archive(&war, &[("WEB-INF/web.xml", "<web-app/>"), ("index.html", "built")]);
let base = root.join("cache");
fs::create_dir_all(&base).unwrap();
let mut c = config();
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
assert!(artifact_is_current(root, &base, &c, &[]));
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&web_xml, "<web-app><edited/></web-app>").unwrap();
assert!(!artifact_is_current(root, &base, &c, &[]));
}
#[test]
fn up_to_date_check_watches_the_modules_the_war_depends_on() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let web = root.join("web");
let dao = root.join("dao");
fs::create_dir_all(web.join("src/main/java")).unwrap();
fs::create_dir_all(web.join("target")).unwrap();
fs::create_dir_all(dao.join("src/main/resources/mapper")).unwrap();
fs::write(web.join("pom.xml"), "<project/>").unwrap();
fs::write(dao.join("pom.xml"), "<project/>").unwrap();
fs::write(web.join("src/main/java/App.java"), "class App {}").unwrap();
let mapper = dao.join("src/main/resources/mapper/UserMapper.xml");
fs::write(&mapper, "<mapper/>").unwrap();
archive(&web.join("target/app.war"), &[("WEB-INF/web.xml", "<web-app/>")]);
let base = root.join("cache");
fs::create_dir_all(&base).unwrap();
let mut c = config();
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
let modules = [PathBuf::from(""), PathBuf::from("web"), PathBuf::from("dao")];
assert!(artifact_is_current(root, &base, &c, &modules));
std::thread::sleep(std::time::Duration::from_millis(20));
fs::write(&mapper, "<mapper><select/></mapper>").unwrap();
assert!(!artifact_is_current(root, &base, &c, &modules), "a dependency module's XML changed");
assert!(artifact_is_current(root, &base, &c, &[]), "without the build's modules only the WAR module counts");
}
#[test]
fn artifact_module_prefers_cwd_then_selected_artifact() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let module_of = |cwd: Option<&str>, artifact: Option<&str>| {
let mut c = config();
c.cwd = cwd.map(|d| ide_core::IpcPath(PathBuf::from(d)));
if let Some(id) = artifact {
c.extras.params.insert("artifact".into(), id.into());
}
artifact_module_dir(root, &c)
};
// The UI keeps `cwd` on the artifact's module; it wins over a stale selection.
assert_eq!(module_of(Some("web"), Some("maven:other/pom.xml")), root.join("web"));
assert_eq!(module_of(None, Some("maven:web/pom.xml")), root.join("web"));
assert_eq!(module_of(None, Some("maven:pom.xml")), root);
assert_eq!(module_of(None, Some("gradle::web")), root.join("web"));
assert_eq!(module_of(None, Some("gradle::")), root);
assert_eq!(module_of(None, Some("gradle:[\"sub\",\":web\"]")), root.join("sub/web"));
assert_eq!(module_of(None, Some("other:whatever")), root);
assert_eq!(module_of(None, None), root);
assert_eq!(module_of(Some("web"), None), root.join("web"));
}
#[test]
+51 -1
View File
@@ -441,7 +441,7 @@ fn jdk_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<Libr
Some(files) => files,
None => {
let java = [".classpath", ".project"].iter().any(|f| root.join(f).is_file())
|| root.join(".idea").is_dir()
|| idea_java_project(root)
|| !s.languages.java.jdk.trim().is_empty()
|| !s.project_structure.source_roots.is_empty();
if !java {
@@ -561,6 +561,27 @@ pub(crate) fn classpath_entries(dir: &Path, text: &str) -> Vec<PathBuf> {
}
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
/// An IntelliJ project (`.idea`) that is a Java one: a Java SDK or language level in
/// `misc.xml`, or a Java module file. GoLand, PyCharm and WebStorm write `.idea` too, so the
/// folder alone doesn't make a project need the JDK.
fn idea_java_project(root: &Path) -> bool {
let idea = root.join(".idea");
if !idea.is_dir() {
return false;
}
let misc = std::fs::read_to_string(idea.join("misc.xml")).unwrap_or_default();
if misc.contains("JavaSDK") || misc.contains("languageLevel=\"JDK_") {
return true;
}
let java_module = |dir: &Path| {
std::fs::read_dir(dir).into_iter().flatten().flatten().any(|e| {
e.path().extension().is_some_and(|x| x == "iml")
&& std::fs::read_to_string(e.path()).is_ok_and(|t| t.contains("JAVA_MODULE"))
})
};
java_module(root) || java_module(&idea)
}
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
let mut roots = named_roots(vec![(name.clone(), jdk.home.clone())]);
@@ -1263,3 +1284,32 @@ mod go_cache_tests {
assert_eq!(groups[1].roots.len(), 1);
}
}
#[cfg(test)]
mod idea_project_tests {
use super::*;
#[test]
fn an_idea_folder_marks_a_java_project_only_when_it_declares_java() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
assert!(!idea_java_project(root));
// GoLand / PyCharm write `.idea` without any Java in it.
std::fs::create_dir_all(root.join(".idea")).unwrap();
std::fs::write(root.join(".idea/misc.xml"), r#"<project version="4"><component name="GOROOT" /></project>"#)
.unwrap();
std::fs::write(root.join(".idea/demo.iml"), r#"<module type="WEB_MODULE" version="4" />"#).unwrap();
assert!(!idea_java_project(root));
// A Java module file, at the root or in `.idea`.
std::fs::write(root.join("app.iml"), r#"<module type="JAVA_MODULE" version="4" />"#).unwrap();
assert!(idea_java_project(root));
std::fs::remove_file(root.join("app.iml")).unwrap();
// The project JDK in misc.xml.
std::fs::write(
root.join(".idea/misc.xml"),
r#"<project version="4"><component name="ProjectRootManager" languageLevel="JDK_17" project-jdk-name="17" project-jdk-type="JavaSDK" /></project>"#,
)
.unwrap();
assert!(idea_java_project(root));
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"$comment": "Human-owned: agents don't change this file or evals/*/golden/ (a PR that does is labelled needs-decision). `*Ms` are upper bounds on medians (the user's view: input → first painted frame), `recall` / `precision` lower bounds vs the golden. Keys: `<scenario>` or `<scenario>@<condition>`. Regressions vs target/eval/baseline.json need both the relative tolerance and the absolute floor.",
"regressionTolerance": 0.2,
"regressionFloorMs": 200,
"defaults": {
"feedbackMs": 100,
"recall": 1,
"precision": 1
},
"scenarios": {}
}
@@ -1,70 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"add",
"addAll",
"addFirst",
"addLast",
"cast",
"clear",
"contains",
"containsAll",
"else",
"equals",
"for",
"forEach",
"fori",
"get",
"getClass",
"getFirst",
"getLast",
"hashCode",
"if",
"indexOf",
"isEmpty",
"iterator",
"lastIndexOf",
"listIterator",
"nn",
"not",
"notify",
"notifyAll",
"null",
"orders.nnull",
"orders.opt",
"orders.syserr",
"orders.sysouf",
"orders.sysout",
"orders.sysoutv",
"par",
"parallelStream",
"remove",
"removeAll",
"removeFirst",
"removeIf",
"removeLast",
"replaceAll",
"retainAll",
"return",
"reversed",
"set",
"size",
"sort",
"sout",
"spliterator",
"stream",
"subList",
"throw",
"toArray",
"toString",
"try",
"var",
"wait",
"while"
]
}
}
@@ -1,43 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"book.nnull",
"book.opt",
"book.syserr",
"book.sysouf",
"book.sysout",
"book.sysoutv",
"byCustomer",
"cast",
"else",
"equals",
"for",
"fori",
"getClass",
"hashCode",
"if",
"largest",
"nn",
"not",
"notify",
"notifyAll",
"null",
"par",
"place",
"return",
"revenueCents",
"size",
"sout",
"throw",
"toString",
"try",
"var",
"wait",
"while"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/junit-jupiter-api-5.11.3.jar/org.junit.jupiter.api/Assertions.java#assertEquals"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/AbstractList.java#AbstractList"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/ArrayList.java#ArrayList"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"jdt://contents/java.base/java.util/Comparator.java#comparingLong"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/com/example/orders/OrderBook.java:28"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": [
"L33:error:Type mismatch: cannot convert from String to int"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": [
"L34:warning:The value of the local variable used is not used"
]
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"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)"
]
}
}
@@ -1,11 +0,0 @@
{
"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"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"declaration": [
"src/main/java/com/example/orders/OrderBook.java:32"
]
}
}
@@ -1,14 +0,0 @@
{
"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"
]
}
}
@@ -1,13 +0,0 @@
{
"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"
]
}
}
@@ -1,12 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L4:implemented",
"L5:implemented"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L14:overriding"
]
}
}
@@ -1,12 +0,0 @@
{
"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"
]
}
}
@@ -1,81 +0,0 @@
{
"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"
]
}
}
@@ -1,12 +0,0 @@
{
"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"
]
}
}
@@ -1,13 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L33:1804 usages",
"L73:226 usages",
"L92:3 usages"
]
}
}
@@ -1,13 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L10:3 usages",
"L3:12 usages",
"L6:2 usages"
]
}
}
@@ -1,14 +0,0 @@
{
"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"
]
}
}
@@ -1,13 +0,0 @@
{
"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"
]
}
}
@@ -1,13 +0,0 @@
{
"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"
]
}
}
@@ -1,16 +0,0 @@
{
"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"
]
}
}
@@ -1,13 +0,0 @@
{
"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"
]
}
}
@@ -1,21 +0,0 @@
{
"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"
]
}
}
@@ -1,18 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"completions": [
"Pageable",
"PageableArgumentResolver",
"PageableDefault",
"PageableExecutionUtils",
"PageableHandlerMethodArgumentResolver",
"PageableHandlerMethodArgumentResolverCustomizer",
"PageableHandlerMethodArgumentResolverSupport",
"PageableUtils"
]
}
}
@@ -1,11 +0,0 @@
{
"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"
]
}
}
@@ -1,11 +0,0 @@
{
"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"
]
}
}
@@ -1,11 +0,0 @@
{
"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"
]
}
}
@@ -1,9 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"problems": []
}
}
@@ -1,11 +0,0 @@
{
"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"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L33:overridden"
]
}
}
@@ -1,11 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"markers": [
"L29:overridden"
]
}
}
@@ -1,20 +0,0 @@
{
"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"
]
}
}
@@ -1,13 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"lenses": [
"L38:82 usages",
"L76:3 usages",
"L84:3 usages"
]
}
}
@@ -1,13 +0,0 @@
{
"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"
]
}
}
@@ -1,83 +0,0 @@
{
"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"
]
}
}
@@ -1,24 +0,0 @@
{
"tools": {
"jdtls": "1.63.0-202610191322",
"java": "21.0.13.0.2 (Homebrew)"
},
"groups": {
"usages": [
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:108",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:132",
"src/main/java/org/springframework/samples/petclinic/owner/Owner.java:161",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:125",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:161",
"src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java:86",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:153",
"src/main/java/org/springframework/samples/petclinic/owner/PetController.java:187",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:122",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:182",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:229",
"src/test/java/org/springframework/samples/petclinic/service/ClinicServiceTests.java:234",
"src/test/java/org/springframework/samples/petclinic/service/EntityUtils.java:46",
"src/test/java/org/springframework/samples/petclinic/vet/VetTests.java:38"
]
}
}
-838
View File
@@ -1,838 +0,0 @@
/**
* Java code intelligence with jdtls: the steps users take most, timed as they see them and
* checked against `golden/<id>.json`. On `fixtures/java-app` (small, every case visible) and
* on Spring PetClinic (`evals/projects.json`: a real Spring Boot / Maven project).
*/
import { join } from "node:path";
import { track } from "../lib/measure.ts";
import { normalizePath, type EvalScenario, type Measured } from "../lib/scenario.ts";
import type { Session } from "../lib/session.ts";
const SRC = "src/main/java/com/example/orders";
const TEST = "src/test/java/com/example/orders";
const PET = "src/main/java/org/springframework/samples/petclinic";
const TIMEOUT = 30_000;
/** Results a whole file's worth of server requests make up. */
const LONG_TIMEOUT = 60_000;
interface ProbeWindow {
__openideaPerf: {
editorState(): { path: string; head: number; line: number; word: string } | null;
editorText(): string | null;
setCursor(offset: number): void;
insertAfter(after: string, text: string): number;
completions(): string[];
completionPending(): boolean;
diagnostics(): Array<{ line: number; severity: string; message: string }>;
lineAtY(y: number): number;
};
}
/** The visible editor hint, unless it only says a request is pending. */
async function finalHint(s: Session): Promise<string | null> {
return s.page.evaluate(() => {
const el = document.querySelector(".cm-lsp-hint:not([data-pending])") as HTMLElement | null;
return el && el.getClientRects().length ? el.textContent : null;
});
}
/** Where the caret is: `path:line` in the project, `uri#word` in a library class (its line depends on the JDK build). */
async function caretLocation(s: Session): Promise<string | null> {
const st = await s.page.evaluate(() => (window as unknown as ProbeWindow).__openideaPerf.editorState());
if (!st) return null;
const path = normalizePath(s.root, st.path);
return path.includes("://") ? `${path}#${st.word}` : `${path}:${st.line + 1}`;
}
/**
* Where a step starts: inside `word` on the first line containing `line` of `file`, or of the
* library class reached from `file` by going to the declaration of each `via` in turn. `edit`
* changes `file` first; `typed` changes the file reached right before the step, with no time
* for the server to catch up.
*/
interface Place {
file: string;
edit?: Insertion;
via?: Array<[line: string, word: string]>;
typed?: Insertion;
line: string;
word: string;
}
interface Insertion {
after: string;
text: string;
}
async function reach(s: Session, at: Place) {
await s.openFile(at.file);
if (at.edit) await s.edit(at.edit.after, at.edit.text);
for (const [line, word] of at.via ?? []) await s.declarationOf(line, word);
if (!at.typed) await s.caretAt(at.line, at.word);
}
/** What a step does first when its place is `typed`. */
async function type(s: Session, at: Place) {
if (!at.typed) return;
await s.edit(at.typed.after, at.typed.text);
await s.caretAt(at.line, at.word);
}
/** Text that marks an answer from syntax instead of the language server. */
const SYNTAX = /based on syntax|language server not ready/i;
function declaration(
project: string,
id: string,
title: string,
at: Place,
conditions: EvalScenario["conditions"] = ["warm"],
): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions,
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
await type(s, at);
// Moved to the declaration, or a hint saying why not (not one saying it is still looking).
const answered = (_: null, before: { path: string; head: number } | null) => {
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(".cm-lsp-hint:not([data-pending])");
};
const timing = await track(s.page, {
action: "navigate.declaration",
arg: null,
first: answered,
done: answered,
timeoutMs: TIMEOUT,
});
// A note that the answer came from syntax follows the navigation.
await s.page.waitForTimeout(300);
const hint = await finalHint(s);
const landed = await caretLocation(s);
return {
timing,
results: { declaration: landed ? [landed] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
/** Every usage row of the popup, scrolling its virtual list through. */
async function usageRows(s: Session): Promise<string[]> {
const rows = await s.page.evaluate(async () => {
const popup = document.querySelector('[data-testid="usages-popup"]');
if (!popup) return [];
const found = new Set<string>();
const collect = () => {
for (const r of popup.querySelectorAll<HTMLElement>('[data-testid="usage-row"]'))
found.add(`${r.dataset.path}\u0000${Number(r.dataset.line) + 1}`);
};
const scroller = [...popup.querySelectorAll<HTMLElement>("*")].find((el) => el.scrollHeight > el.clientHeight + 1);
collect();
if (scroller) {
for (let top = 0; top < scroller.scrollHeight; top += scroller.clientHeight / 2) {
scroller.scrollTop = top;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
collect();
}
}
return [...found];
});
return rows.map((r) => {
const [path, line] = r.split("\u0000") as [string, string];
return `${normalizePath(s.root, path)}:${line}`;
});
}
/**
* Show Usages at `at`. `widen`: in Project and Libraries, as invoking it again while the popup
* or its hint offers to widen a Project Files search does (the step is that second invocation).
*/
function showUsages(project: string, id: string, title: string, at: Place, widen = false): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
if (!widen) return;
await s.runAction("find.showUsages");
await s.page.waitForFunction(
() =>
document.querySelector('[data-testid="usages-popup"][data-loading="false"][data-scope="project"]') ||
document.querySelector(".cm-lsp-hint[data-usages]"),
undefined,
{ timeout: TIMEOUT },
);
},
async step(s): Promise<Measured> {
await type(s, at);
const timing = await track(s.page, {
action: "find.showUsages",
arg: widen,
// A single usage is jumped to; none says so in a hint.
first: (widen, before) => {
const popup = document.querySelector<HTMLElement>('[data-testid="usages-popup"]');
if (popup)
return (
(!widen || popup.dataset.scope === "libraries") && !!popup.querySelector('[data-testid="usage-row"]')
);
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(widen ? '.cm-lsp-hint[data-scope="libraries"]' : ".cm-lsp-hint[data-usages]");
},
done: (widen, before) => {
const popup = document.querySelector<HTMLElement>('[data-testid="usages-popup"]');
if (popup) return (!widen || popup.dataset.scope === "libraries") && popup.dataset.loading === "false";
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(widen ? '.cm-lsp-hint[data-scope="libraries"]' : ".cm-lsp-hint[data-usages]");
},
timeoutMs: TIMEOUT,
});
const popup = await s.page.$('[data-testid="usages-popup"]');
const rows = popup ? await usageRows(s) : [];
// Without a popup the hint gives the count: the only usage was jumped to (or the caret is on it).
const count = await s.page.evaluate(
() => (document.querySelector(".cm-lsp-hint[data-usages]") as HTMLElement | null)?.dataset.usages ?? null,
);
const single = !popup && count === "1" ? await caretLocation(s) : null;
const note = await s.page.evaluate(
() => document.querySelector('[data-testid="usages-note"]')?.textContent ?? null,
);
const hint = await finalHint(s);
return {
timing,
results: { usages: single ? [single] : rows },
degraded: note ?? (hint && SYNTAX.test(hint) ? hint : null),
};
},
};
}
/** Every row of the targets chooser (`path:line`), scrolling its virtual list through. */
async function chooserRows(s: Session): Promise<string[]> {
const rows = await s.page.evaluate(async () => {
const popup = document.querySelector('[data-testid="list-popup"]');
if (!popup) return [];
const found = new Set<string>();
const collect = () => {
for (const r of popup.querySelectorAll<HTMLElement>('[data-testid="list-popup-item"]'))
if (r.dataset.path) found.add(`${r.dataset.path}\u0000${Number(r.dataset.line) + 1}`);
};
const scroller = [...popup.querySelectorAll<HTMLElement>("*")].find((el) => el.scrollHeight > el.clientHeight + 1);
collect();
if (scroller) {
for (let top = 0; top < scroller.scrollHeight; top += scroller.clientHeight / 2) {
scroller.scrollTop = top;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
collect();
}
}
return [...found];
});
return rows.map((r) => {
const [path, line] = r.split("\u0000") as [string, string];
return `${normalizePath(s.root, path)}:${line}`;
});
}
/** Go to Implementation: the chooser's rows once it stops searching, or the only target. */
function implementation(project: string, id: string, title: string, at: Place): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
const before = await caretLocation(s);
// The chooser (busy while the server looks on), the only target, or a hint.
const answered = (searching: boolean, before: { path: string; head: number } | null) => {
const popup = document.querySelector<HTMLElement>('[data-testid="list-popup"]');
if (popup) return searching || popup.getAttribute("aria-busy") !== "true";
const st = (window as unknown as ProbeWindow).__openideaPerf.editorState();
if (st && before && (st.path !== before.path || st.head !== before.head)) return true;
return !!document.querySelector(".cm-lsp-hint:not([data-pending])");
};
// Checks run in the page from their source, so the shared one goes in as the argument's source.
const timing = await track(s.page, {
action: "navigate.implementation",
arg: answered.toString(),
first: (src, before) => (new Function(`return ${src}`)() as typeof answered)(true, before),
done: (src, before) => (new Function(`return ${src}`)() as typeof answered)(false, before),
timeoutMs: TIMEOUT,
});
const popup = await s.page.$('[data-testid="list-popup"]');
const rows = popup ? await chooserRows(s) : [];
const at = await caretLocation(s);
const hint = await finalHint(s);
return {
timing,
results: { implementations: popup ? rows : at && at !== before ? [at] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
/**
* What opening `file`, or the library class reached from it through `via` (see `Place`), brings
* up beside its text: every `selector` with its line, as `L<line>:<its kind or text>`.
*/
function onOpen(
project: string,
id: string,
title: string,
file: string,
via: NonNullable<Place["via"]>,
group: string,
selector: string,
): EvalScenario {
const last = via[via.length - 1];
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
if (!last) return;
await s.openFile(file);
for (const [line, word] of via.slice(0, -1)) await s.declarationOf(line, word);
await s.caretAt(last[0], last[1]);
},
async step(s): Promise<Measured> {
const items = (selector: string) => {
const probe = (window as unknown as ProbeWindow).__openideaPerf;
const out: string[] = [];
for (const m of document.querySelectorAll<HTMLElement>(selector)) {
// A lens is a row of its own above the line it is about.
const r = (m.closest(".cm-openidea-lens")?.nextElementSibling ?? m).getBoundingClientRect();
out.push(`L${probe.lineAtY(r.top + r.height / 2) + 1}:${m.dataset.kind ?? m.textContent}`);
}
return out.sort();
};
const timing = await track(s.page, {
// A library class has no path to open: it is reached the way users do, which also shows its declaration.
...(last ? { action: "navigate.declaration" } : { openFile: join(s.root, file) }),
arg: selector,
first: (selector, before) =>
(window as unknown as ProbeWindow).__openideaPerf.editorState()?.path !== before?.path &&
!!document.querySelector(selector),
done: () => true,
signature: items,
settleMs: 2000,
timeoutMs: LONG_TIMEOUT,
});
return { timing, results: { [group]: await s.page.evaluate(items, selector) } };
},
};
}
const GUTTER = '.cm-editor [class*="cm-openidea-lineMarker-"]';
function gutterOnOpen(project: string, id: string, title: string, file: string, via: Place["via"] = []): EvalScenario {
return onOpen(project, id, title, file, via, "markers", GUTTER);
}
function lensOnOpen(project: string, id: string, title: string, file: string, via: Place["via"] = []): EvalScenario {
return onOpen(project, id, title, file, via, "lenses", ".cm-editor .cm-openidea-lensAction");
}
/** Errors and warnings in the active editor: hints and notes come and go with the server's settings. */
const problems = () =>
(window as unknown as ProbeWindow).__openideaPerf
.diagnostics()
.filter((d) => d.severity === "error" || d.severity === "warning")
.map((d) => `L${d.line + 1}:${d.severity}:${d.message}`)
.sort();
/** Waits for what the previous run left in the active editor to go. */
async function noErrors(s: Session) {
await s.page.waitForFunction(() => !document.querySelector(".cm-lintRange-error"), undefined, { timeout: TIMEOUT });
}
/** Opening a correct `file`: nothing is reported in it, once the server has had its say. */
function diagnosticsOnOpen(project: string, id: string, title: string, file: string): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup() {},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
openFile: join(s.root, file),
arg: null,
first: () => !!document.querySelector(".cm-content .cm-line"),
done: () => true,
signature: problems,
settleMs: 3000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Typing `edit` into a correct file: the error shows under it. */
function diagnosticsAfterEdit(project: string, id: string, title: string, at: Place, edit: Insertion): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await noErrors(s);
await s.keepText();
},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
run: (edit) => (window as unknown as ProbeWindow).__openideaPerf.insertAfter(edit.after, edit.text),
arg: edit,
// The typed text answers the keystroke; the error is the result.
feedback: () => true,
first: () => !!document.querySelector(".cm-lintRange-error"),
done: () => true,
signature: problems,
settleMs: 2000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Typing `fix` elsewhere repairs the mistake `edit` made: only the server can clear its error. */
function diagnosticsAfterFix(
project: string,
id: string,
title: string,
at: Place,
edit: Insertion,
fix: Insertion,
): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await noErrors(s);
await s.edit(edit.after, edit.text);
await s.page.waitForSelector(".cm-lintRange-error", { timeout: TIMEOUT });
},
async step(s): Promise<Measured> {
const timing = await track(s.page, {
run: (fix) => (window as unknown as ProbeWindow).__openideaPerf.insertAfter(fix.after, fix.text),
arg: fix,
feedback: () => true,
first: () => !document.querySelector(".cm-lintRange-error"),
done: () => true,
signature: problems,
settleMs: 2000,
timeoutMs: TIMEOUT,
});
return { timing, results: { problems: await s.page.evaluate(problems) } };
},
};
}
/** Code Completion right after typing `edit`: the lookup's labels. */
function completion(project: string, id: string, title: string, file: string, edit: Insertion): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => s.openFile(file),
async step(s): Promise<Measured> {
const end = await s.edit(edit.after, edit.text);
await s.page.evaluate((o) => (window as unknown as ProbeWindow).__openideaPerf.setCursor(o), end);
const timing = await track(s.page, {
action: "code.completion",
arg: null,
first: () => (window as unknown as ProbeWindow).__openideaPerf.completions().length > 0,
// Quicker sources show first: every source has answered once it stops being pending.
done: () => !(window as unknown as ProbeWindow).__openideaPerf.completionPending(),
timeoutMs: TIMEOUT,
});
const labels = await s.page.evaluate(() => (window as unknown as ProbeWindow).__openideaPerf.completions());
return { timing, results: { completions: labels } };
},
};
}
/** Quick Documentation: the start of what the popup says. */
function quickDoc(project: string, id: string, title: string, at: Place): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
setup: (s) => reach(s, at),
async step(s): Promise<Measured> {
const shown = () => !!document.querySelector('[data-testid="doc-popup"], .cm-lsp-hint:not([data-pending])');
const text = () =>
document.querySelector('[data-testid="doc-popup"]')?.textContent?.replace(/\s+/g, " ").trim().slice(0, 300) ??
"";
const timing = await track(s.page, {
action: "code.quickDocumentation",
arg: null,
feedback: () => !!document.querySelector('[data-testid="doc-popup"]'),
first: shown,
done: shown,
signature: text,
settleMs: 500,
timeoutMs: TIMEOUT,
});
const doc = await s.page.evaluate(text);
const hint = await finalHint(s);
return {
timing,
results: { documentation: doc ? [doc] : [] },
degraded: hint && SYNTAX.test(hint) ? hint : null,
};
},
};
}
interface RenameAnswer {
edit: { files: Array<{ path?: string; edits?: Array<{ range: { start: { line: number } }; new_text: string }> }> };
}
/** Rename in place to `newName`, then ↵: every edit it makes (a preview is left unapplied). */
function rename(project: string, id: string, title: string, at: Place, newName: string): EvalScenario {
return {
id,
title,
fixture: project,
server: "jdtls",
conditions: ["warm"],
async setup(s) {
await reach(s, at);
await s.keepText();
await s.runAction("refactor.rename");
await s.page.waitForSelector(".cm-lsp-inplace-primary", { timeout: TIMEOUT });
await s.page.keyboard.type(newName);
},
async step(s): Promise<Measured> {
const answered = (newName: string) => {
if (document.querySelector('[data-testid="refactor-preview"]')) return true;
if (document.querySelector(".cm-lsp-hint:not([data-pending]):not(.cm-lsp-inplace-hint)")) return true;
return (
!document.querySelector(".cm-lsp-inplace") &&
!!(window as unknown as ProbeWindow).__openideaPerf.editorText()?.includes(newName)
);
};
const timing = await track(s.page, {
run: () =>
document.querySelector(".cm-content")?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
bubbles: true,
cancelable: true,
}),
),
arg: newName,
first: answered,
done: answered,
timeoutMs: TIMEOUT,
});
// The preview only counts per file: where the name lands is in the answer it shows. A server
// may merge nearby occurrences into one edit, so every line of its new text counts.
const answer = s.lastAnswer.get("lsp_rename") as RenameAnswer | undefined;
const changes = (answer?.edit.files ?? []).flatMap((f) =>
(f.edits ?? []).flatMap((e) =>
e.new_text
.split("\n")
.flatMap((l, i) =>
l.includes(newName) ? [`${normalizePath(s.root, f.path!)}:${e.range.start.line + i + 1}`] : [],
),
),
);
return { timing, results: { changes } };
},
};
}
const APP = "java-app";
const PETCLINIC = "spring-petclinic";
/** From `OrderBook` into the JDK's `ArrayList` / `Collection` sources. */
const BOOK = `${SRC}/OrderBook.java`;
const TO_ARRAYLIST: [string, string] = ["private final List<Order> orders = new ArrayList<>();", "ArrayList"];
/** A new method above the rest of `OrderBook`, calling `revenueCents`. */
const TWICE = "\n public long twice() {\n return revenueCents();\n }\n";
const TYPE_ERROR: Insertion = { after: "public int size() {", text: '\n int broken = "text";' };
const TO_STREAM: [string, string] = ["return orders.stream().mapToLong(Order::totalCents).sum();", "stream"];
export const JAVA_SCENARIOS: EvalScenario[] = [
declaration(
APP,
"java.app.declaration.project",
"Go to declaration: project method",
{ file: `${SRC}/App.java`, line: "long revenue = book.revenueCents();", word: "revenueCents" },
["warm", "cold"],
),
declaration(APP, "java.app.declaration.jdk", "Go to declaration: JDK class", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: TO_ARRAYLIST[1],
}),
declaration(APP, "java.app.declaration.dependency", "Go to declaration: dependency method (JUnit)", {
file: `${TEST}/OrderBookTest.java`,
line: "assertEquals(2500, book.revenueCents());",
word: "assertEquals",
}),
showUsages(APP, "java.app.usages.record", "Show Usages: record used across files", {
file: `${SRC}/Order.java`,
line: "public record Order(",
word: "Order",
}),
showUsages(APP, "java.app.usages.method", "Show Usages: method used through calls and method references", {
file: `${SRC}/Order.java`,
line: "public long totalCents() {",
word: "totalCents",
}),
gutterOnOpen(APP, "java.app.gutter.interface", "Gutter markers: open an interface", `${SRC}/PricingRule.java`),
gutterOnOpen(
APP,
"java.app.gutter.subclass",
"Gutter markers: open an overriding subclass",
`${SRC}/BulkDiscount.java`,
),
// The JDK's sources: their lines depend on its build (`java` in .github/tool-versions.env).
declaration(APP, "java.app.declaration.jdkMethod", "Go to declaration: JDK static method", {
file: BOOK,
line: "return orders.stream().max(Comparator.comparingLong(Order::totalCents));",
word: "comparingLong",
}),
declaration(APP, "java.app.declaration.inJdkSource", "Go to declaration from a JDK class: its superclass", {
file: BOOK,
via: [TO_ARRAYLIST],
line: "extends AbstractList<E>",
word: "AbstractList",
}),
showUsages(
APP,
"java.app.usages.jdkMethodLibraries",
"Show Usages in Project and Libraries: JDK method called once in the project",
{
file: BOOK,
line: "return orders.stream().max(Comparator.comparingLong(Order::totalCents));",
word: "comparingLong",
},
true,
),
showUsages(APP, "java.app.usages.inJdkSource", "Show Usages from a JDK class: a default method the project calls", {
file: BOOK,
via: [TO_STREAM],
line: "default Stream<E> stream() {",
word: "stream",
}),
implementation(APP, "java.app.implementation.jdkInterface", "Go to Implementation: JDK interface", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: "List",
}),
gutterOnOpen(APP, "java.app.gutter.jdkClass", "Gutter markers: open a JDK class", BOOK, [TO_ARRAYLIST]),
lensOnOpen(APP, "java.app.lens.project", "Usage lenses: open a project class", `${SRC}/Order.java`),
lensOnOpen(APP, "java.app.lens.jdkClass", "Usage lenses: open a JDK class", BOOK, [
["public Optional<Order> largest() {", "Optional"],
]),
implementation(APP, "java.app.implementation.projectInterface", "Go to Implementation: project interface", {
file: `${SRC}/PricingRule.java`,
line: "public interface PricingRule {",
word: "PricingRule",
}),
implementation(APP, "java.app.implementation.interfaceMethod", "Go to Implementation: interface method", {
file: `${SRC}/PricingRule.java`,
line: "long price(Order order);",
word: "price",
}),
// Right after an edit: results follow the new text, unsaved and with its lines moved.
declaration(APP, "java.app.edit.declaration", "Go to declaration right after typing above the target", {
file: BOOK,
typed: { after: "public class OrderBook {", text: TWICE },
line: "return revenueCents();",
word: "revenueCents",
}),
showUsages(APP, "java.app.edit.usagesSameFile", "Show Usages right after typing a call in the same file", {
file: BOOK,
typed: { after: "public class OrderBook {", text: TWICE },
line: "public long revenueCents() {",
word: "revenueCents",
}),
showUsages(APP, "java.app.edit.usagesOtherFile", "Show Usages after typing a call in another file", {
file: BOOK,
edit: {
after: "public class OrderBook {",
text: "\n public long firstTotal() {\n return orders.get(0).totalCents();\n }\n",
},
via: [["return orders.get(0).totalCents();", "totalCents"]],
line: "public long totalCents() {",
word: "totalCents",
}),
diagnosticsAfterEdit(
APP,
"java.app.diagnostics.afterError",
"Diagnostics: type a type error",
{ file: BOOK, line: "public class OrderBook {", word: "OrderBook" },
TYPE_ERROR,
),
diagnosticsAfterFix(
APP,
"java.app.diagnostics.afterFix",
"Diagnostics: declare the missing variable",
{ file: BOOK, line: "public class OrderBook {", word: "OrderBook" },
{ after: "public int size() {", text: "\n int used = missing;" },
{ after: "public class OrderBook {", text: "\n private final int missing = 0;" },
),
completion(APP, "java.app.completion.jdkMember", "Code Completion: members of a JDK type", BOOK, {
after: "public int size() {",
text: "\n orders.",
}),
completion(
APP,
"java.app.completion.projectMember",
"Code Completion: members of a project class",
`${SRC}/App.java`,
{
after: "long revenue = book.revenueCents();",
text: "\n book.",
},
),
quickDoc(APP, "java.app.doc.project", "Quick Documentation: project method", {
file: BOOK,
line: "public Order place(String customer, String product, int quantity, long unitPriceCents) {",
word: "place",
}),
quickDoc(APP, "java.app.doc.jdk", "Quick Documentation: JDK class", {
file: BOOK,
line: TO_ARRAYLIST[0],
word: TO_ARRAYLIST[1],
}),
rename(
APP,
"java.app.rename.acrossFiles",
"Rename: method used across files",
{ file: `${SRC}/Order.java`, line: "public long totalCents() {", word: "totalCents" },
"orderTotalCents",
),
rename(
APP,
"java.app.rename.local",
"Rename: local variable",
{ file: BOOK, line: "Order order = new Order(customer, product, quantity, unitPriceCents);", word: "order" },
"placedOrder",
),
declaration(
PETCLINIC,
"java.petclinic.declaration.project",
"Go to declaration: Spring Data query method in the project",
{
file: `${PET}/owner/OwnerController.java`,
line: "return owners.findByLastNameStartingWith(lastname, pageable);",
word: "findByLastNameStartingWith",
},
["warm", "cold"],
),
declaration(
PETCLINIC,
"java.petclinic.declaration.inheritedLibrary",
"Go to declaration: method inherited from a Spring Data interface",
{ file: `${PET}/owner/OwnerController.java`, line: "this.owners.save(owner);", word: "save" },
),
declaration(PETCLINIC, "java.petclinic.declaration.libraryClass", "Go to declaration: Spring Data class", {
file: `${PET}/owner/OwnerController.java`,
line: "Pageable pageable = PageRequest.of(validatedPage - 1, pageSize);",
word: "PageRequest",
}),
showUsages(
PETCLINIC,
"java.petclinic.usages.entity",
"Show Usages: entity class used across controllers, repositories and tests",
{ file: `${PET}/owner/Owner.java`, line: "public class Owner extends Person {", word: "Owner" },
),
showUsages(
PETCLINIC,
"java.petclinic.usages.inheritedMethod",
"Show Usages: base-class getter called through subclasses",
{
file: `${PET}/model/BaseEntity.java`,
line: "public Integer getId() {",
word: "getId",
},
),
gutterOnOpen(
PETCLINIC,
"java.petclinic.gutter.baseClass",
"Gutter markers: open a base entity",
`${PET}/model/BaseEntity.java`,
),
gutterOnOpen(
PETCLINIC,
"java.petclinic.gutter.midClass",
"Gutter markers: open a mid-hierarchy entity",
`${PET}/model/Person.java`,
),
lensOnOpen(PETCLINIC, "java.petclinic.lens.entity", "Usage lenses: open an entity", `${PET}/owner/Owner.java`),
implementation(PETCLINIC, "java.petclinic.implementation.baseClass", "Go to Implementation: base entity", {
file: `${PET}/model/BaseEntity.java`,
line: "public class BaseEntity implements Serializable {",
word: "BaseEntity",
}),
diagnosticsOnOpen(
PETCLINIC,
"java.petclinic.diagnostics.cleanFile",
"Diagnostics: open a controller that compiles",
`${PET}/owner/OwnerController.java`,
),
completion(
PETCLINIC,
"java.petclinic.completion.libraryClass",
"Code Completion: class from a dependency, not imported",
`${PET}/owner/PetValidator.java`,
{ after: "public class PetValidator implements Validator {", text: "\n private Pageab" },
),
quickDoc(PETCLINIC, "java.petclinic.doc.library", "Quick Documentation: Spring Data class", {
file: `${PET}/owner/OwnerController.java`,
line: "Pageable pageable = PageRequest.of(validatedPage - 1, pageSize);",
word: "PageRequest",
}),
rename(
PETCLINIC,
"java.petclinic.rename.acrossFiles",
"Rename: entity method used across controllers and tests",
{ file: `${PET}/owner/Owner.java`, line: "public void addVisit(Integer petId, Visit visit) {", word: "addVisit" },
"recordVisit",
),
];
-40
View File
@@ -1,40 +0,0 @@
import { describe, expect, test } from "vitest";
import { compare, toolDrift } from "./golden.ts";
describe("compare", () => {
test("a perfect answer", () => {
expect(compare({ usages: ["a:1", "b:2"] }, { usages: ["b:2", "a:1"] })).toEqual({
recall: 1,
precision: 1,
missing: [],
extra: [],
});
});
test("dropped and extra results lower recall and precision", () => {
const c = compare({ usages: ["a:1", "c:3"] }, { usages: ["a:1", "b:2"] });
expect(c.recall).toBe(0.5);
expect(c.precision).toBe(0.5);
expect(c.missing).toEqual(["usages: b:2"]);
expect(c.extra).toEqual(["usages: c:3"]);
});
test("groups are part of the identity", () => {
expect(compare({ read: ["a:1"] }, { write: ["a:1"] }).recall).toBe(0);
});
test("nothing expected", () => {
expect(compare({}, {})).toMatchObject({ recall: 1, precision: 1 });
expect(compare({ usages: ["a:1"] }, {})).toMatchObject({ recall: 1, precision: 0 });
});
test("nothing found", () => {
expect(compare({}, { usages: ["a:1"] })).toMatchObject({ recall: 0, precision: 0 });
});
});
test("toolDrift lists tools that changed since the recording", () => {
expect(toolDrift({ tools: { jdtls: "1.63.0", java: "21" }, groups: {} }, { jdtls: "1.64.0", java: "21" })).toEqual([
"jdtls: 1.63.0 → 1.64.0",
]);
});
-62
View File
@@ -1,62 +0,0 @@
/**
* Golden results: what a scenario found when a person last confirmed it, keyed to the
* language server and JDK that found it. Compared as sets (recall / precision), not by order.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
/** Result group → items (e.g. `src/…/OrderBook.java:14`); repeated items count once. */
export type ResultSet = Record<string, string[]>;
export interface Golden {
/** Tool versions the results were recorded with (`jdtls`, `java`). */
tools: Record<string, string>;
groups: ResultSet;
}
export interface Comparison {
recall: number;
precision: number;
/** In the golden, not found (`group: item`). */
missing: string[];
/** Found, not in the golden. */
extra: string[];
}
const keys = (set: ResultSet) => new Set(Object.entries(set).flatMap(([g, items]) => items.map((i) => `${g}: ${i}`)));
export function compare(found: ResultSet, golden: ResultSet): Comparison {
const got = keys(found);
const want = keys(golden);
const missing = [...want].filter((k) => !got.has(k));
const extra = [...got].filter((k) => !want.has(k));
const hit = want.size - missing.length;
return {
// Nothing expected and nothing found is a perfect answer.
recall: want.size ? hit / want.size : 1,
precision: got.size ? hit / got.size : want.size ? 0 : 1,
missing: missing.sort(),
extra: extra.sort(),
};
}
export function readGolden(file: string): Golden | null {
return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as Golden) : null;
}
export function writeGolden(file: string, golden: Golden) {
mkdirSync(dirname(file), { recursive: true });
const sorted = Object.fromEntries(
Object.entries(golden.groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([g, items]) => [g, [...new Set(items)].sort()]),
);
writeFileSync(file, JSON.stringify({ tools: golden.tools, groups: sorted }, null, 2) + "\n");
}
/** Tools that differ between the golden's recording and this run (`name: was → now`). */
export function toolDrift(golden: Golden, tools: Record<string, string>): string[] {
return Object.entries(golden.tools)
.filter(([k, v]) => tools[k] !== undefined && tools[k] !== v)
.map(([k, v]) => `${k}: ${v} → ${tools[k]}`);
}
-86
View File
@@ -1,86 +0,0 @@
/** The machine and tools a report was measured with: a baseline only holds for the same ones. */
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { arch, cpus, platform, release, totalmem } from "node:os";
import { dirname, join } from "node:path";
export interface Fingerprint {
cpu: string;
cores: number;
memoryGb: number;
os: string;
}
export function fingerprint(): Fingerprint {
return {
cpu: cpus()[0]?.model.trim() ?? "unknown",
cores: cpus().length,
memoryGb: Math.round(totalmem() / 2 ** 30),
os: `${platform()} ${release()} ${arch()}`,
};
}
export function sameMachine(a: Fingerprint | undefined, b: Fingerprint): boolean {
return !!a && a.cpu === b.cpu && a.cores === b.cores && a.memoryGb === b.memoryGb && a.os === b.os;
}
/** `KEY=value` lines (`#` comments), as GitHub Actions' `$GITHUB_ENV` takes them; keys lower-cased. */
export function pinnedTools(file: string): Record<string, string> {
if (!existsSync(file)) return {};
const out: Record<string, string> = {};
for (const line of readFileSync(file, "utf8").split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(\S+)/.exec(line);
if (m) out[m[1]!.toLowerCase()] = m[2]!;
}
return out;
}
function which(cmd: string): string | null {
try {
const found = execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { encoding: "utf8" })
.split("\n")[0]!
.trim();
return found ? realpathSync(found) : null;
} catch {
return null;
}
}
/** jdtls's version from its core bundle (`org.eclipse.jdt.ls.core_1.63.0.202610191322.jar` → `1.63.0-202610191322`). */
function jdtlsVersion(): string | null {
const bin = which("jdtls");
if (!bin) return null;
for (let dir = dirname(bin), i = 0; i < 4; dir = dirname(dir), i++) {
for (const plugins of [join(dir, "plugins"), join(dir, "libexec", "plugins")]) {
if (!existsSync(plugins)) continue;
const core = readdirSync(plugins).find((f) => /^org\.eclipse\.jdt\.ls\.core_.*\.jar$/.test(f));
const m = core && /_(\d+\.\d+\.\d+)\.(\d+)\.jar$/.exec(core);
if (m) return `${m[1]}-${m[2]}`;
}
}
return null;
}
/**
* The JDK projects compile against, whose sources library navigation opens: `JAVA_HOME`'s (it
* wins for its Java version; jdtls itself runs on a newer one when it needs to), else the `java`
* on PATH. Runtime version and vendor (`-XshowSettings` prints to stderr).
*/
function javaVersion(): string | null {
const home = process.env.JAVA_HOME;
const java = home ? join(home, "bin", process.platform === "win32" ? "java.exe" : "java") : "java";
const r = spawnSync(java, ["-XshowSettings:properties", "-version"], { encoding: "utf8" });
const props = `${r.stdout ?? ""}${r.stderr ?? ""}`;
const version = /java\.runtime\.version = (\S+)/.exec(props)?.[1];
const vendor = /java\.vendor = (.+)/.exec(props)?.[1]?.trim();
return version ? `${version}${vendor ? ` (${vendor})` : ""}` : null;
}
export function toolVersions(): Record<string, string> {
const tools: Record<string, string> = {};
const jdtls = jdtlsVersion();
if (jdtls) tools.jdtls = jdtls;
const java = javaVersion();
if (java) tools.java = java;
return tools;
}
-154
View File
@@ -1,154 +0,0 @@
/**
* Timing one user action in the page: the action is run, then every animation frame checks
* what the user would see. A condition true at the start of a frame is painted by it.
*/
import type { Page } from "@playwright/test";
/**
* A check of the page, run in it every frame: self-contained (it is re-created from its
* source), given the arguments passed to `track` and the editor state before the action.
*/
export type Check<A> = (arg: A, before: EditorSnapshot | null) => boolean;
export interface EditorSnapshot {
path: string;
head: number;
line: number;
word: string;
}
export interface Tracked {
/** Something visible changed in answer to the action (popup, hint, spinner, caret / editor moved). */
feedbackMs: number;
/** The first result is shown. */
firstResultMs: number;
/** The final result is shown and nothing is loading. */
completeMs: number;
}
export interface TrackSpec<A> {
/** The action run (`actionArg` its argument), the file opened (`openFile`), or a check-like step in the page (`run`). */
action?: string;
actionArg?: unknown;
openFile?: string;
run?: (arg: A) => unknown;
arg: A;
first: Check<A>;
done: Check<A>;
/** Extra feedback beyond the default (popup, hint, status spinner, editor or caret changed). */
feedback?: Check<A>;
/**
* For results with no loading indicator (gutter markers): done once `done` holds and
* `signature` has not changed for `settleMs`; complete is when it last changed.
*/
signature?: (arg: A) => unknown;
settleMs?: number;
timeoutMs: number;
}
export class TimeoutError extends Error {
readonly partial: Partial<Tracked>;
constructor(message: string, partial: Partial<Tracked>) {
super(message);
this.partial = partial;
}
}
export async function track<A>(page: Page, spec: TrackSpec<A>): Promise<Tracked> {
const r = await page.evaluate(
({ action, actionArg, openFile, run, arg, first, done, feedback, signature, settleMs, timeoutMs }) => {
const w = window as unknown as {
__openideaPerf: {
runAction(id: string, arg?: unknown): Promise<boolean>;
openFile(path: string): Promise<void>;
editorState(): EditorSnapshot | null;
};
};
const make = (src: string | null) =>
src
? (new Function("arg", "before", `return (${src})(arg, before)`) as (a: unknown, b: unknown) => boolean)
: null;
const checks = { first: make(first)!, done: make(done)!, feedback: make(feedback) };
const sign = signature
? (new Function("arg", `return JSON.stringify((${signature})(arg))`) as (a: unknown) => string)
: null;
let lastSig: string | null = null;
let lastChange = 0;
const before = w.__openideaPerf.editorState();
const visible = (sel: string) =>
[...document.querySelectorAll(sel)].some((el) => (el as HTMLElement).getClientRects().length > 0);
const anyFeedback = () => {
if (visible('.cm-lsp-hint, [data-testid="usages-popup"], [role="dialog"], [role="listbox"]')) return true;
const now = w.__openideaPerf.editorState();
if ((now?.path ?? null) !== (before?.path ?? null) || (now && before && now.head !== before.head)) return true;
return checks.feedback?.(arg, before) ?? false;
};
const times: { feedbackMs?: number; firstResultMs?: number; completeMs?: number } = {};
const t0 = performance.now();
let failed: unknown = null;
const started = run
? new Promise((resolve) => resolve(make(run)!(arg, before)))
: openFile
? w.__openideaPerf.openFile(openFile)
: w.__openideaPerf.runAction(action!, actionArg);
void started.catch((e: unknown) => (failed = e));
return new Promise<{ times: typeof times; timedOut: boolean; error: string | null }>((resolve) => {
const tick = () => {
const t = performance.now() - t0;
try {
if (times.feedbackMs === undefined && anyFeedback()) times.feedbackMs = t;
if (times.firstResultMs === undefined && checks.first(arg, before)) times.firstResultMs = t;
if (sign) {
const sig = sign(arg);
if (sig !== lastSig) {
lastSig = sig;
lastChange = t;
}
}
const settled = !sign || t - lastChange >= (settleMs ?? 0);
if (times.firstResultMs !== undefined && settled && checks.done(arg, before)) {
// Session.reset waits out the time in which invoking it again widens it.
if (action === "find.showUsages")
(window as unknown as { __openideaEvalShowUsagesAt?: number }).__openideaEvalShowUsagesAt =
performance.now();
times.completeMs = sign ? Math.max(lastChange, times.firstResultMs) : t;
times.feedbackMs ??= t;
return resolve({ times, timedOut: false, error: null });
}
} catch (e) {
return resolve({ times, timedOut: false, error: String(e) });
}
if (failed) return resolve({ times, timedOut: false, error: String((failed as Error).message ?? failed) });
if (t > timeoutMs) return resolve({ times, timedOut: true, error: null });
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
},
{
action: spec.action,
actionArg: spec.actionArg,
openFile: spec.openFile,
run: spec.run?.toString() ?? null,
arg: spec.arg,
first: spec.first.toString(),
done: spec.done.toString(),
feedback: spec.feedback?.toString() ?? null,
signature: spec.signature?.toString() ?? null,
settleMs: spec.settleMs,
timeoutMs: spec.timeoutMs,
},
);
const what = spec.action ?? (spec.openFile ? `open ${spec.openFile}` : "step");
if (r.error) throw new Error(`${what}: ${r.error}`);
if (r.timedOut) {
const seen =
r.times.firstResultMs !== undefined
? "a first result but no final one"
: r.times.feedbackMs !== undefined
? "feedback but no result"
: "nothing";
throw new TimeoutError(`${what} timed out after ${spec.timeoutMs} ms (${seen})`, r.times);
}
return r.times as Tracked;
}
-70
View File
@@ -1,70 +0,0 @@
/** Where a scenario's project comes from: `fixtures/<name>`, or an open-source project pinned in `evals/projects.json`. */
import { execFileSync, spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
interface Pinned {
repo: string;
commit: string;
remove?: string[];
prepare?: string[];
}
function pinned(repoRoot: string, name: string): Pinned | null {
const all = JSON.parse(readFileSync(join(repoRoot, "evals", "projects.json"), "utf8")) as Record<string, Pinned>;
return all[name] ?? null;
}
const quiet = { stdio: "ignore" as const };
/** The pinned checkout, cloned and prepared once; returns its directory. */
function checkout(repoRoot: string, name: string, p: Pinned): string {
const dir = join(repoRoot, "target", "eval", "sources", name);
if (!existsSync(join(dir, ".git"))) {
console.log(`Cloning ${p.repo}…`);
mkdirSync(join(dir, ".."), { recursive: true });
execFileSync("git", ["clone", "-q", "--filter=blob:none", p.repo, dir], quiet);
}
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim();
if (head !== p.commit) {
const has = spawnSync("git", ["cat-file", "-e", `${p.commit}^{commit}`], { cwd: dir }).status === 0;
if (!has) execFileSync("git", ["fetch", "-q", "origin", p.commit], { cwd: dir, ...quiet });
execFileSync("git", ["checkout", "-q", "--detach", p.commit], { cwd: dir, ...quiet });
}
const marker = join(repoRoot, "target", "eval", "sources", `.${name}.prepared`);
if (p.prepare && (!existsSync(marker) || readFileSync(marker, "utf8") !== p.commit)) {
console.log(`Preparing ${name}: ${p.prepare.join(" ")}…`);
const r = spawnSync(p.prepare[0]!, p.prepare.slice(1), { cwd: dir, stdio: ["ignore", "ignore", "inherit"] });
if (r.status !== 0) throw new Error(`preparing ${name} failed: ${p.prepare.join(" ")}`);
writeFileSync(marker, p.commit);
}
return dir;
}
/**
* A fresh copy of `name` at `dest`, as its own git repository (edits and IDE state of an
* earlier session don't carry over; it isn't part of the checkout `target/` sits in).
*/
export function copyProject(repoRoot: string, name: string, dest: string) {
const fixture = join(repoRoot, "fixtures", name);
const p = existsSync(fixture) ? null : pinned(repoRoot, name);
if (!p && !existsSync(fixture)) throw new Error(`no fixtures/${name} and no ${name} in evals/projects.json`);
const src = p ? checkout(repoRoot, name, p) : fixture;
rmSync(dest, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
mkdirSync(join(dest, ".."), { recursive: true });
cpSync(src, dest, { recursive: true, filter: (f) => !/[\\/](\.git|target|build|\.gradle|\.openidea)$/.test(f) });
for (const r of p?.remove ?? []) rmSync(join(dest, r), { recursive: true, force: true });
const env = {
...process.env,
GIT_AUTHOR_NAME: "eval",
GIT_AUTHOR_EMAIL: "eval@localhost",
GIT_COMMITTER_NAME: "eval",
GIT_COMMITTER_EMAIL: "eval@localhost",
};
for (const args of [
["init", "-q"],
["add", "-A"],
["commit", "-q", "--no-verify", "-m", "eval project"],
])
execFileSync("git", args, { cwd: dest, env, ...quiet });
}
-35
View File
@@ -1,35 +0,0 @@
/** What an eval scenario is: a project, a step the user takes, and what they should see. */
import type { ResultSet } from "./golden.ts";
import type { Tracked } from "./measure.ts";
import type { Session } from "./session.ts";
import type { Condition } from "./verdict.ts";
export interface Measured {
timing: Tracked;
results?: ResultSet;
/** Why the answer is weaker than a semantic one, when the UI says so. */
degraded?: string | null;
}
export interface EvalScenario {
/** Stable id: `<language>.<group>.<case>`, used by `--only`, goldens and budgets. */
id: string;
title: string;
/** `fixtures/<fixture>`. */
fixture: string;
/** The language server whose readiness the warm runs wait for. */
server: string;
conditions: Condition[];
/** Brings the idle workbench to where the step starts (open a file, place the caret). */
setup(s: Session): Promise<void>;
/** The measured step. */
step(s: Session): Promise<Measured>;
}
/** Project-relative path, or a library class's URI without its machine-specific query. */
export function normalizePath(root: string, path: string): string {
if (path.startsWith(root + "/")) return path.slice(root.length + 1);
const uri = /^[a-z]+:\/\//.exec(path);
if (uri) return path.replace(/[?#].*$/, "");
return path;
}
-403
View File
@@ -1,403 +0,0 @@
/**
* One eval session: a release `openidea-server --stdio` with its own home, and the production
* workbench in Chromium connected to it as remote development connects a window (the Tauri
* runtime from `smoke/tauriShim.ts`, commands and notifications relayed by Playwright).
*/
import { mkdirSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, sep } from "node:path";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { installInPage } from "../../perf/lib/inpage.ts";
import { Server } from "../../smoke/server.ts";
import { copyProject } from "./projects.ts";
import { installTauriShim, type ShimWindow } from "../../smoke/tauriShim.ts";
/** How long after its answer Show Usages invoked again widens it (`HINT_MS` in code-insight/usages.ts), plus a margin. */
const SHOW_USAGES_HINT_MS = 4200;
export interface IpcCall {
cmd: string;
/** Milliseconds since the session started. */
start: number;
ms: number;
ok: boolean;
/** JSON length of the result (or error). */
bytes: number;
/** Array length of the result, when it is one. */
items?: number;
}
export interface TaskSpan {
id: number;
title: string;
start: number;
end: number | null;
}
/** What the server did, for diagnostics: every command, background task and server status. */
export class Timeline {
readonly t0 = Date.now();
readonly calls: IpcCall[] = [];
readonly tasks = new Map<number, TaskSpan>();
readonly servers: Array<{ at: number; name: string; status: string; message: string | null }> = [];
/** Last time anything happened (a call ended, a task or status changed). */
lastActivity = 0;
inFlight = 0;
now() {
return Date.now() - this.t0;
}
onEvent(event: { type: string } & Record<string, unknown>) {
if (event.type === "task") {
const id = event.id as number;
const span = this.tasks.get(id) ?? { id, title: String(event.title), start: this.now(), end: null };
if (event.done) span.end = this.now();
this.tasks.set(id, span);
this.lastActivity = this.now();
} else if (event.type === "languageServer") {
this.servers.push({
at: this.now(),
name: String(event.name),
status: String(event.status),
message: (event.message as string | null) ?? null,
});
this.lastActivity = this.now();
}
}
runningTasks(): TaskSpan[] {
return [...this.tasks.values()].filter((t) => t.end === null);
}
serverStatus(name: string): string | null {
for (let i = this.servers.length - 1; i >= 0; i--)
if (this.servers[i]!.name === name) return this.servers[i]!.status;
return null;
}
/** The calls and tasks overlapping `[from, to]` (session milliseconds). */
window(from: number, to: number) {
return {
calls: this.calls.filter((c) => c.start <= to && c.start + c.ms >= from),
tasks: [...this.tasks.values()].filter((t) => t.start <= to && (t.end ?? Infinity) >= from),
};
}
}
export interface SessionOptions {
/** `fixtures/<name>` or a project pinned in `evals/projects.json`. */
fixture: string;
/** Where the project copy and the server's home go. */
workDir: string;
/** Keep the server's home (installed tools, language server workspaces) from an earlier session. */
keepHome: boolean;
/** `OPENIDEA_LOG` for the server. */
log?: string;
}
export class Session {
readonly timeline = new Timeline();
readonly root: string;
readonly home: string;
page!: Page;
private context!: BrowserContext;
private server!: Server;
private relayQueue: Promise<unknown> = Promise.resolve();
private pendingNotes: Array<[kind: "channel" | "event", a: unknown, b: unknown]> = [];
/** The last answer to each command that succeeded, for what the UI only summarises. */
readonly lastAnswer = new Map<string, unknown>();
/** Editors changed by a scenario: path → the text to put back. */
private readonly edited = new Map<string, string>();
readonly pageErrors: string[] = [];
private constructor(opts: SessionOptions) {
this.root = join(opts.workDir, "projects", opts.fixture);
this.home = join(opts.workDir, "home");
}
static async start(browser: Browser, url: string, repoRoot: string, opts: SessionOptions): Promise<Session> {
const s = new Session(opts);
copyProject(repoRoot, opts.fixture, s.root);
if (!opts.keepHome) rmSync(s.home, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
mkdirSync(s.home, { recursive: true });
s.server = await Server.start(s.home, opts.log ? { OPENIDEA_LOG: opts.log } : {});
s.server.onEvent = (target, event) => {
if (target !== null && target !== "main") return;
s.timeline.onEvent(event as { type: string });
s.notify("event", target, event);
};
s.server.onChannel = (id, data) => s.notify("channel", id, data);
await s.server.invoke("project_trust_set", { path: s.root, trusted: true, folder: false });
s.context = await browser.newContext({
baseURL: url,
viewport: { width: 1400, height: 900 },
deviceScaleFactor: 1,
});
await s.context.exposeFunction("__openideaRelay", async (cmd: string, args: unknown) => {
const start = s.timeline.now();
s.timeline.inFlight++;
try {
const value = await s.server.invoke(cmd, args);
s.record(cmd, start, true, value);
return { ok: value ?? null };
} catch (err) {
s.record(cmd, start, false, err);
return { err };
} finally {
s.timeline.inFlight--;
s.timeline.lastActivity = s.timeline.now();
}
});
await s.context.addInitScript(installTauriShim, {
directories: { 12: tmpdir(), 21: homedir() },
sep: sep as "/" | "\\",
});
await s.context.addInitScript(() => {
const w = window as unknown as ShimWindow & {
__openideaRelay(cmd: string, args: unknown): Promise<{ ok: unknown } | { err: unknown }>;
};
w.__openideaForward = async (cmd, args) => {
const r = await w.__openideaRelay(cmd, args);
if ("err" in r) throw r.err;
return r.ok;
};
});
await s.context.addInitScript(installInPage);
s.page = await s.context.newPage();
s.page.on("pageerror", (e) => s.pageErrors.push(e.message));
if (process.env.OPENIDEA_EVAL_DEBUG) {
s.page.on("console", (m) => console.log("[page]", m.type(), m.text().slice(0, 300)));
s.page.on("pageerror", (e) => console.log("[pageerror]", e.message));
}
await s.page.goto("/");
await s.page.waitForFunction(() => "__openideaPerf" in window && "__openideaShim" in window);
await s.page.evaluate(
(root) =>
(window as unknown as { __openideaPerf: { openProject(p: string): Promise<void> } }).__openideaPerf.openProject(
root,
),
s.root,
);
await s.page.waitForSelector('[role="tree"][aria-label="Project"] [role="treeitem"]', { timeout: 60_000 });
return s;
}
private record(cmd: string, start: number, ok: boolean, value: unknown) {
let bytes = 0;
try {
bytes = JSON.stringify(value ?? null).length;
} catch {
// not serialisable: size unknown
}
const items = Array.isArray(value)
? value.length
: value && typeof value === "object" && Array.isArray((value as { locations?: unknown }).locations)
? (value as { locations: unknown[] }).locations.length
: undefined;
this.timeline.calls.push({ cmd, start, ms: this.timeline.now() - start, ok, bytes, items });
if (ok) this.lastAnswer.set(cmd, value);
}
/** Delivers server notifications in order, batched per turn of the event loop. */
private notify(kind: "channel" | "event", a: unknown, b: unknown) {
this.pendingNotes.push([kind, a, b]);
if (this.pendingNotes.length > 1) return;
this.relayQueue = this.relayQueue
.then(() => new Promise((r) => setImmediate(r)))
.then(() => {
const batch = this.pendingNotes;
this.pendingNotes = [];
return this.page
?.evaluate((notes) => {
const shim = (window as unknown as ShimWindow).__openideaShim;
for (const [kind, a, b] of notes) {
if (kind === "channel") shim.channel(a as number, b);
else shim.event(a as string | null, b);
}
}, batch)
.catch(() => {});
});
}
runAction(id: string, arg?: unknown): Promise<boolean> {
return this.page.evaluate(
async ({ id, arg }) => {
const w = window as unknown as {
__openideaPerf: { runAction(id: string, arg?: unknown): Promise<boolean> };
__openideaEvalShowUsagesAt?: number;
};
const ran = await w.__openideaPerf.runAction(id, arg);
if (id === "find.showUsages") w.__openideaEvalShowUsagesAt = performance.now();
return ran;
},
{ id, arg },
);
}
/** Opens `rel` (project-relative, or a URI) in an editor and waits until its text is shown. */
async openFile(rel: string) {
const path = /^[a-z]+:\/\//.test(rel) ? rel : join(this.root, rel);
const bare = rel.replace(/[?#].*$/, "");
const name = bare.slice(bare.lastIndexOf("/") + 1);
await this.page.evaluate(
async ({ path, name }) => {
const w = window as unknown as {
__perf: { waitShown(s: string, t: string | null, ms: number): Promise<number> };
__openideaPerf: { openFile(p: string): Promise<void> };
};
await w.__openideaPerf.openFile(path);
await w.__perf.waitShown('[role="tab"][aria-selected="true"]', name, 30_000);
await w.__perf.waitShown(".cm-content .cm-line", null, 30_000);
},
{ path, name },
);
}
/**
* Goes to the declaration of `word` on `line` of the active editor and waits until another
* file shows (a library class: its `jdt://` URI, which carries what only the server knows).
*/
async declarationOf(line: string, word: string): Promise<string> {
await this.caretAt(line, word);
type Probe = { __openideaPerf: { editorState(): { path: string } | null } };
const from = await this.page.evaluate(() => (window as unknown as Probe).__openideaPerf.editorState()?.path);
await this.runAction("navigate.declaration");
const handle = await this.page.waitForFunction(
(from) => {
const path = (window as unknown as Probe).__openideaPerf.editorState()?.path;
return path && path !== from && document.querySelector(".cm-content .cm-line") ? path : null;
},
from,
{ timeout: 30_000 },
);
return (await handle.jsonValue()) as string;
}
/** Puts the caret inside `word` on the active editor's first line containing `line` (both literal). */
async caretAt(line: string, word: string) {
const text = await this.editorText();
const at = text.indexOf(line);
if (at < 0) throw new Error(`no line containing ${JSON.stringify(line)} in the active editor`);
const inLine = line.indexOf(word);
if (inLine < 0) throw new Error(`${JSON.stringify(word)} is not in ${JSON.stringify(line)}`);
// Inside the word, as a click on it would put it.
const target = at + inLine + Math.floor(word.length / 2);
await this.page.evaluate(
(o) => (window as unknown as { __openideaPerf: { setCursor(o: number): void } }).__openideaPerf.setCursor(o),
target,
);
}
/** The active editor's whole text. */
editorText(): Promise<string> {
return this.page.evaluate(() => {
const text = (
window as unknown as { __openideaPerf: { editorText(): string | null } }
).__openideaPerf.editorText();
if (text === null) throw new Error("no active editor");
return text;
});
}
/** Keeps the active editor's text, for `reset` to put back whatever the scenario changes. */
async keepText() {
const { path, text } = await this.page.evaluate(() => {
const probe = (
window as unknown as {
__openideaPerf: { editorState(): { path: string } | null; editorText(): string | null };
}
).__openideaPerf;
return { path: probe.editorState()?.path, text: probe.editorText() };
});
if (path === undefined || text === null) throw new Error("no active editor");
if (!this.edited.has(path)) this.edited.set(path, text);
}
/** Inserts `text` after the first `after` in the active editor; the offset after it. */
async edit(after: string, text: string): Promise<number> {
await this.keepText();
return this.page.evaluate(
({ after, text }) =>
(
window as unknown as { __openideaPerf: { insertAfter(a: string, t: string): number } }
).__openideaPerf.insertAfter(after, text),
{ after, text },
);
}
private async restoreEdits() {
for (const [path, text] of this.edited) {
await this.page.evaluate(
async ({ path, text }) => {
const probe = (
window as unknown as {
__openideaPerf: {
openFile(p: string): Promise<void>;
editorState(): { path: string } | null;
editorText(): string | null;
setText(t: string): void;
};
}
).__openideaPerf;
await probe.openFile(path);
while (probe.editorState()?.path !== path) await new Promise(requestAnimationFrame);
if (probe.editorText() !== text) probe.setText(text);
},
{ path, text },
);
await this.runAction("file.save");
}
this.edited.clear();
}
/** Waits until `server` is ready and nothing has happened for `quietMs`: no task, no command in flight. */
async settle(server: string, quietMs = 2000, timeoutMs = 5 * 60_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const t = this.timeline;
const quiet =
t.serverStatus(server) === "ready" &&
t.runningTasks().length === 0 &&
t.inFlight === 0 &&
t.now() - t.lastActivity >= quietMs;
if (quiet) return;
if (Date.now() > deadline) {
throw new Error(
`the IDE did not settle within ${timeoutMs / 1000}s: ${server} ${t.serverStatus(server) ?? "not started"}, ` +
`tasks ${JSON.stringify(t.runningTasks().map((x) => x.title))}, ${t.inFlight} command(s) in flight`,
);
}
await new Promise((r) => setTimeout(r, 200));
}
}
/** Back to an idle workbench: popups dismissed, edits undone, every editor closed. */
async reset() {
// Show Usages invoked again within 4 s of its hint widens the previous search: wait it out.
const since = await this.page.evaluate(() => {
const at = (window as unknown as { __openideaEvalShowUsagesAt?: number }).__openideaEvalShowUsagesAt;
return at === undefined ? Infinity : performance.now() - at;
});
if (since < SHOW_USAGES_HINT_MS) await this.page.waitForTimeout(SHOW_USAGES_HINT_MS - since);
await this.page.keyboard.press("Escape");
await this.page.keyboard.press("Escape");
await this.restoreEdits();
await this.runAction("editor.closeAllTabs");
await this.page.waitForFunction(() => !document.querySelector('[role="tab"][aria-selected="true"]'), undefined, {
timeout: 10_000,
});
}
serverLog(): string {
return this.server.log();
}
async close() {
await this.context?.close().catch(() => {});
await this.server?.close();
}
}
-86
View File
@@ -1,86 +0,0 @@
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