From c4b87ae09e058a2f011cc32c219f1de409c98fbd Mon Sep 17 00:00:00 2001 From: qiaojianlong <109515705+qjlqyf@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:23:39 +0800 Subject: [PATCH] perf(java): prewarm configuration navigation and share results between hover and click (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuration navigation from YAML/properties still waited for a cold index the first time and repeated the lookup between the ⌘-hover probe and the click. - Trusted projects with a Maven/Gradle/Eclipse build file start Java shortly after opening. Once JDT reports ServiceReady, the app calls the new openidea.java.configuration.warmup command: a low-priority background job builds the configuration index one project at a time (open YAML/properties modules first), waits for workspace builds, has a 30 s budget per project, and only fills free cache room. - Index builds take a cancellable per-project lock instead of one global lock, so a background build never delays another project, and a warmed snapshot is dropped if the Java model changed meanwhile. - In YAML/properties the editor underlines the whole key (hyphens and dots included), hover and Go to Definition share one bounded request per key, and the declaration chooser names the whole key. Bundle java-usages 1.6.0 is rebuilt reproducibly with build.py against JDT LS 1.63.0-202610191322 (SHA-256 33cfa1bb…). The real-JDT harness checks that the first lookup reuses the warmed index. Co-authored-by: qjlqyf <109515705+qjlqyf@users.noreply.github.com> --- crates/ide-host/src/project.rs | 1 + crates/ide-host/src/trust.rs | 1 + crates/ide-lsp/src/jdtls.rs | 58 ++++- crates/ide-lsp/tests/mock_server.rs | 52 ++++- crates/ide-sdk/src/java.rs | 2 +- e2e/configurationNavigation.fixture.ts | 100 ++++++++ e2e/configurationNavigation.html | 10 + e2e/configurationNavigation.spec.ts | 91 ++++++++ java/java-usages/CONFIGURATION.md | 32 ++- java/java-usages/MANIFEST.MF | 2 +- java/java-usages/README.md | 6 +- java/java-usages/openidea-java-usages.jar | Bin 108367 -> 122814 bytes java/java-usages/plugin.xml | 1 + .../configuration/ConfigurationCommand.java | 57 ++++- .../configuration/ConfigurationWarmup.java | 111 +++++++++ java/java-usages/tests/configuration.py | 21 +- knip.ts | 5 +- .../lsp/configurationNavigation.test.ts | 218 ++++++++++++++++++ src/editor/lsp/configurationNavigation.ts | 160 +++++++++++++ src/editor/lsp/index.ts | 2 + src/editor/lsp/navigation.ts | 70 ++++-- src/editor/lsp/syntaxFallback.test.ts | 75 ++++++ 22 files changed, 1038 insertions(+), 37 deletions(-) create mode 100644 e2e/configurationNavigation.fixture.ts create mode 100644 e2e/configurationNavigation.html create mode 100644 e2e/configurationNavigation.spec.ts create mode 100644 java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java create mode 100644 src/editor/lsp/configurationNavigation.test.ts create mode 100644 src/editor/lsp/configurationNavigation.ts diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 9fd0c213..2ca25b50 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -526,6 +526,7 @@ impl Host { })); } self.insert_project(window, project.clone()); + project.lsp.prewarm_java_configuration(); if let Some(bridge) = &project.kotlin_bridge { bridge.start(&self.runtime); } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 32e54b96..5b8749ca 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -121,6 +121,7 @@ impl crate::Host { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } project.lsp.set_execution_allowed(true); + project.lsp.prewarm_java_configuration(); } else { self.environment.project_closed(); project.agents.shutdown(); diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 360dfa96..94767ace 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -327,6 +327,36 @@ impl LspManager { Ok(changed) } + /// Start Java in trusted build-root projects during idle startup, so configuration + /// navigation can warm after import even when no Java editor has been opened. + pub fn prewarm_java_configuration(self: &Arc) { + let weak = Arc::downgrade(self); + self.runtime.spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + let Some(mgr) = weak.upgrade() else { return }; + if mgr.shutting_down.load(std::sync::atomic::Ordering::Acquire) + || !mgr.execution_allowed.load(std::sync::atomic::Ordering::Acquire) + { + return; + } + let root = mgr.project_root.clone(); + let java_project = tokio::task::spawn_blocking(move || { + ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"] + .iter() + .any(|name| root.join(name).is_file()) + }) + .await + .unwrap_or(false); + if java_project { + for server in mgr.servers_for(&LanguageId::new("java"), &mgr.project_root) { + if server.name == SERVER { + mgr.ensure_started(&server); + } + } + } + }); + } + pub(crate) fn java_import_ready(self: &Arc, server: Arc, generation: u64) { let mgr = self.clone(); self.runtime.spawn(async move { @@ -334,6 +364,32 @@ impl LspManager { if let Err(e) = mgr.java_configure_compiler(&server, &run).await { tracing::warn!("jdtls: cannot apply compiler defaults: {e}"); } + // The extension only queues bounded, low-priority work; this request + // never waits for the configuration index itself to be built. + const WARMUP: &str = "openidea.java.configuration.warmup"; + if server.running_gen(generation).is_some() + && run.caps.read().commands().iter().any(|command| command == WARMUP) + { + let preferred: Vec<_> = mgr + .docs + .lock() + .values() + .filter(|doc| matches!(doc.language.as_str(), "yaml" | "properties")) + .map(|doc| doc.uri.to_string()) + .collect(); + let result = tokio::time::timeout( + Duration::from_secs(5), + run.peer.request::<_, Value>( + "workspace/executeCommand", + json!({"command": WARMUP, "arguments": ["start", preferred]}), + None, + ), + ) + .await; + if !matches!(result, Ok(Ok(_))) { + tracing::debug!("jdtls: configuration warmup unavailable; retaining on-demand navigation"); + } + } }); } @@ -758,7 +814,7 @@ mod tests { for wrapped in [false, true] { let mut cfg = java_config(); let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}}); - cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings }); + cfg.settings = Some(if wrapped { json!({ "settings": settings }) } else { settings }); configure_source_paths(&mut cfg, Some(vec!["custom".into()])); let settings = crate::handler::all_settings(&cfg); assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"])); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 68adee62..9cfee9b1 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2377,7 +2377,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() { let source = sources.join("p/Outer.java"); std::fs::write(&source, "package p; class Outer {} ").unwrap(); s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}")); - s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]})); + s.respond("workspace/executeCommand", json!({ "org.eclipse.jdt.ls.core.sourcePaths": [sources] })); for class in ["Outer", "Outer$Nested", "Outer$1"] { let path = h.root.join(format!("target/classes/p/{class}.class")); let resolved = h.mgr.java_class_file(&path, None).await.unwrap(); @@ -3394,7 +3394,7 @@ async fn gopls_interactive_command_validates_before_execution_and_applies_edits( let reply: Value = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(s.peer().request( "workspace/applyEdit", - json!({"edit": edit}), + json!({ "edit": edit }), None, )) }) @@ -3679,6 +3679,54 @@ async fn configuration_extension_failure_preserves_native_java_navigation() { assert_eq!(s.requested("textDocument/definition").len(), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + let s = h.launcher.state("jdtls"); + h.mgr.prewarm_java_configuration(); + h.mgr.prewarm_java_configuration(); + h.wait_status("jdtls", ServerStatus::Ready).await; + assert_eq!(s.launches.load(Ordering::SeqCst), 1); + assert!(s.requested("workspace/executeCommand").is_empty()); + let config = h.root.join("application.yml"); + h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n"); + s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap(); + eventually(|| { + s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup") + }) + .await; + let requests = s.requested("workspace/executeCommand"); + let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap(); + assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]])); + assert!(s.notified("textDocument/didOpen").is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() { + let mut fixtures = Vec::new(); + for mode in ["untrusted", "closed", "non-java"] { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + if mode != "non-java" { + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + } + if mode == "untrusted" { + h.mgr.set_execution_allowed(false); + } + h.mgr.prewarm_java_configuration(); + if mode == "closed" { + h.mgr.shutdown_all().await; + } + fixtures.push(h); + } + tokio::time::sleep(Duration::from_millis(2200)).await; + for h in fixtures { + assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0); + } +} + /// The configuration command indexes the whole project, so Go to Definition (and /// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java /// symbols in a Spring file go straight to JDT. diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 50d470b5..b81b9dd0 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1186,7 +1186,7 @@ mod kotlin_bridge_bundle_tests { /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { - let extension = data_dir.join("java-usages-1.5.0.jar"); + let extension = data_dir.join("java-usages-1.6.0.jar"); let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar"); if std::fs::read(&extension).is_ok_and(|old| old == bytes) { clean_usages_bundles(data_dir, &extension); diff --git a/e2e/configurationNavigation.fixture.ts b/e2e/configurationNavigation.fixture.ts new file mode 100644 index 00000000..613b30c4 --- /dev/null +++ b/e2e/configurationNavigation.fixture.ts @@ -0,0 +1,100 @@ +/** Real CodeMirror mouse/navigation path with a deliberately slow IPC boundary. + * Semantic correctness and JVM timings live in the real-JDT configuration harness. + */ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { commands } from "../src/platform/ipc"; +import { usePlatform } from "../src/platform/platformInfo"; +import { provideEditorService } from "../src/workbench/contributions"; +import { lspDoc } from "../src/editor/lsp/lsp"; +import { navigationExtension } from "../src/editor/lsp/navigation"; +import { hoverExtension } from "../src/editor/lsp/hover"; +import { lspTheme } from "../src/editor/lsp/theme"; + +Object.assign(window, { __TAURI_INTERNALS__: {} }); +usePlatform.setState({ os: "macos" }); +document.body.innerHTML = ` +
+0`; +const configHost = document.querySelector("#config")!; +const javaHost = document.querySelector("#java")!; +let requests = 0; +let hoverRequests = 0; +let targetLine = 0; +let clickedAt = 0; +const latency: number[] = []; +commands.lspGoto = async () => { + document.querySelector("#requests")!.textContent = String(++requests); + await new Promise((resolve) => setTimeout(resolve, 500)); + return { + status: "ok", + data: [ + { + path: "/fixture/Settings.java", + uri: null, + range: { start: { line: targetLine, col: 24 }, end: { line: targetLine, col: 31 } }, + }, + ], + }; +}; +commands.lspHover = async () => { + hoverRequests++; + return { + status: "ok", + data: { contents: { kind: "plaintext", value: "dingtalk.driver-class-name String" }, range: null }, + }; +}; +commands.lspDocumentLinks = async () => ({ status: "ok", data: [] }); +commands.lspDocumentHighlights = async () => ({ status: "ok", data: [] }); +commands.cancelRequest = async () => ({ status: "ok", data: null }); +const java = new EditorView({ + parent: javaHost, + state: EditorState.create({ + doc: "class Settings { String agentId; }", + extensions: [ + lspDoc.of({ doc: 2, path: "/fixture/Settings.java", language: "java", uiMode: "Java", readOnly: false }), + navigationExtension(), + ], + }), +}); +const config = new EditorView({ + parent: configHost, + state: EditorState.create({ + doc: "dingtalk:\n driver-class-name: demo\n", + extensions: [ + EditorState.languageData.of(() => [{ wordChars: "-" }]), + lspDoc.of({ doc: 1, path: "/fixture/application.yml", language: "yaml", uiMode: "YAML", readOnly: false }), + navigationExtension(), + hoverExtension(), + lspTheme, + ], + }), +}); +config.contentDOM.addEventListener( + "mousedown", + () => { + clickedAt = performance.now(); + }, + true, +); +provideEditorService({ + openFile: async (path, options) => { + javaHost.hidden = false; + configHost.hidden = true; + java.dispatch({ selection: { anchor: java.state.doc.line((options?.selection?.line ?? 0) + 1).from + 24 } }); + document.querySelector("#opened")!.textContent = `${path}:${options?.selection?.line}`; + latency.push(performance.now() - clickedAt); + }, +} as Parameters[0]); +document.querySelector("#back")!.addEventListener("click", () => { + javaHost.hidden = true; + configHost.hidden = false; +}); +document.querySelector("#edit")!.addEventListener("click", () => { + java.dispatch({ changes: { from: 0, insert: "// moved\n" } }); + targetLine++; +}); +Object.assign(window, { navigationMetrics: () => ({ requests, hoverRequests, latency, injectedServerDelayMs: 500 }) }); diff --git a/e2e/configurationNavigation.html b/e2e/configurationNavigation.html new file mode 100644 index 00000000..6850f67b --- /dev/null +++ b/e2e/configurationNavigation.html @@ -0,0 +1,10 @@ + + + + + Configuration navigation fixture + + + + + diff --git a/e2e/configurationNavigation.spec.ts b/e2e/configurationNavigation.spec.ts new file mode 100644 index 00000000..5221dc93 --- /dev/null +++ b/e2e/configurationNavigation.spec.ts @@ -0,0 +1,91 @@ +import { writeFile } from "node:fs/promises"; +import { test, expect, type Page } from "@playwright/test"; + +async function keyPosition(page: Page) { + const line = page.locator("#config .cm-line").filter({ hasText: "driver-class-name" }); + const box = (await line.boundingBox())!; + // Skip the two-space indentation, land within the actual key. + return { x: box.x + 45, y: box.y + box.height / 2 }; +} +async function open(page: Page) { + await page.addInitScript(() => Object.defineProperty(Navigator.prototype, "platform", { get: () => "MacIntel" })); + await page.goto("/e2e/configurationNavigation.html"); + await expect(page.locator("#config .cm-content")).toContainText("driver-class-name"); +} +async function metrics(page: Page) { + return page.evaluate(() => + ( + window as unknown as { + navigationMetrics: () => { + requests: number; + hoverRequests: number; + latency: number[]; + injectedServerDelayMs: number; + }; + } + ).navigationMetrics(), + ); +} + +test("modifier hover and repeated clicks share results; editing Java invalidates them", async ({ page }, info) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await open(page); + const key = await keyPosition(page); + await page.keyboard.down("Meta"); + await page.mouse.move(key.x, key.y); + await expect(page.locator(".cm-lsp-link")).toHaveText("driver-class-name"); + await expect(page.locator(".cm-lsp-link")).toHaveCSS("cursor", "pointer"); + await expect(page.locator(".cm-lsp-link")).toHaveCSS("color", "rgb(33, 92, 200)"); + expect((await metrics(page)).hoverRequests).toBe(0); + await expect(page.locator(".cm-lsp-nav-info")).toHaveCount(0); + await page.screenshot({ path: info.outputPath("configuration-link.png") }); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0"); + await page.keyboard.up("Meta"); + for (let i = 0; i < 10; i++) { + await page.getByRole("button", { name: "Back to configuration" }).click(); + await page.keyboard.down("Meta"); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#java")).toBeVisible(); + await page.keyboard.up("Meta"); + } + const warm = await metrics(page); + expect(warm.requests).toBe(1); + expect(warm.latency).toHaveLength(11); + const evidence = info.outputPath("warm-navigation.json"); + await writeFile(evidence, JSON.stringify(warm, null, 2)); + await info.attach("warm-navigation.json", { path: evidence, contentType: "application/json" }); + await page.getByRole("button", { name: "Edit Java target" }).click(); + await page.getByRole("button", { name: "Back to configuration" }).click(); + await page.keyboard.down("Meta"); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:1"); + expect((await metrics(page)).requests).toBe(2); + expect(errors).toEqual([]); +}); + +test("clicking before the hover query completes shares the in-flight request", async ({ page }, info) => { + await open(page); + const key = await keyPosition(page); + await page.keyboard.down("Meta"); + await page.mouse.move(key.x, key.y); + await expect(page.locator("#requests")).toHaveText("1"); + await page.mouse.click(key.x, key.y); + await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0"); + const result = await metrics(page); + expect(result.requests).toBe(1); + await info.attach("inflight-navigation.json", { + body: JSON.stringify(result, null, 2), + contentType: "application/json", + }); +}); + +test("ordinary hover still shows configuration documentation", async ({ page }) => { + await open(page); + const key = await keyPosition(page); + await page.mouse.move(key.x, key.y); + await expect(page.locator(".cm-lsp-hover")).toContainText("dingtalk.driver-class-name String"); + expect((await metrics(page)).requests).toBe(0); + expect((await metrics(page)).hoverRequests).toBeGreaterThan(0); +}); diff --git a/java/java-usages/CONFIGURATION.md b/java/java-usages/CONFIGURATION.md index 11d122a0..ef12eb1c 100644 --- a/java/java-usages/CONFIGURATION.md +++ b/java/java-usages/CONFIGURATION.md @@ -52,12 +52,27 @@ modules, skips nested independent modules and build output, and honors ignore files for disk discovery. It does not guess a global association from matching text in unrelated projects. -Indexing is lazy. The first explicit navigation/search builds a compact snapshot; +Trusted projects with a Maven/Gradle/Eclipse build file at the root start Java a +moment after opening, even without a Java editor open. Once JDT reports +ServiceReady and compiler defaults are applied, the app asks the extension to warm +the configuration index (`openidea.java.configuration.warmup`). A low-priority +system job waits for workspace builds, then builds the same snapshot navigation +uses, one Java project at a time: modules with open YAML/properties files first, +then modules with open Java editors. At most 64 modules are queued, each attempt +has a cooperative 30-second budget, repeated readiness does not repeat finished +work, and warming only fills free room within the retention limits below (it never +evicts an index a lookup built). A snapshot outdated while it was being built is +dropped. Untrusted projects do not start Java; anything warming skips keeps +on-demand indexing. + +Otherwise indexing is lazy. The first explicit navigation/search builds a compact snapshot; ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations; ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST construction for ordinary Java units, but every accepted association is resolved -semantically. Warm requests reuse the snapshot. Java model changes (sources on +semantically. Builds hold a per-project lock that a waiting lookup can cancel, so a +background build of one project never delays another, and a lookup in the same +project reuses the result. Warm requests reuse the snapshot. Java model changes (sources on disk, classpath, archives) invalidate it conservatively, including dependent-type changes; a fingerprint of working copies with unsaved changes covers edits before they are saved. Opening or closing an editor, reconciling, building, and non-Java @@ -65,6 +80,14 @@ resource changes (configuration files are parsed by the client; metadata files a checked by timestamp) keep it. This is **snapshot invalidation**, not a fully incremental Java semantic index. +In YAML/properties, ⌘/Ctrl-hover underlines the whole key, hyphens and dots +included, and doesn't open the documentation popup. Hover and Go to Definition share +one request per key: a click after the hover reuses its answer. The editor keeps up +to 128 answers / 4,096 locations, drops them on edits, file and project changes, +workspace edits and language-server restarts, and expires them after 30 seconds as a +backstop. Empty answers and errors are not kept, since the import may still be in +progress. + Metadata/JAR timestamps are checked before reuse. The client caches parsed disk configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and performs bounded discovery/parsing on a blocking worker. It does not continuously @@ -74,7 +97,7 @@ Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidat metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem entries per root, and depth limits on recursive bindings/YAML. Java requests and references are cancellable. File/association scan limits and unreadable files carry the -existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a +existing incomplete-result indicator; unsupported syntax keys are omitted. Navigation before warming finishes can take seconds on a cold project; classpath import and source download remain owned by JDT LS. ## Deliberate limits @@ -105,7 +128,8 @@ cargo test -p ide-language -p ide-lsp The isolated real-server harness covers custom bindings, inheritance, nesting, collections/maps, records, constructor aliases, factory methods, consumers, constant keys, negative identity matches, dependency metadata/source locations, -reverse queries, and unsaved-change/close invalidation. Optional stress fixtures +reverse queries, warming (queued once, reused by the first lookup, cancellable), and +unsaved-change/close invalidation. Optional stress fixtures add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`, `spring.datasource.url` and `spring.jackson.date-format` through dependency diff --git a/java/java-usages/MANIFEST.MF b/java/java-usages/MANIFEST.MF index 564dd5ba..78425a4a 100644 --- a/java/java-usages/MANIFEST.MF +++ b/java/java-usages/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: OpenIDEA Java usages Bundle-SymbolicName: ai.openidea.usages;singleton:=true -Bundle-Version: 1.5.0 +Bundle-Version: 1.6.0 Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, diff --git a/java/java-usages/README.md b/java/java-usages/README.md index ed5aa0d8..cb596fa3 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity. ## Rebuild and delivery The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as -`java-usages-1.5.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.6.0.jar`, and added alongside existing initialization bundles. Users need no compiler, Maven or Python. Manifest/install version must change when replacing the bundle because OSGi caches bundles. @@ -83,6 +83,10 @@ Version 1.5.0 answers `{locations, complete}`: `complete` when the type is decla dependencies' implementations after the first ones. The search also covers the project's Kotlin classes attached by the Kotlin bridge; its reproducible SHA-256 is `7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`. +Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configuration indexes in a +low-priority background job after import, and builds them under cancellable per-project locks +instead of one global lock; its reproducible SHA-256 is +`33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 8208527be9d600bebbc6279788879c9827a8b5e4..47428a1ed5096fd172f1376fd6e20f3023c61d52 100644 GIT binary patch delta 22716 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 z zqy)LEGX9km!t7pb{+aZ~Wog=z|<8X5Rrk|!KuN){ZX)ymm@GH;^ zwS*s(gD`Tq7;_9_t3wsP=+Zu5Rx3a=b5Cp7T92^`(vc_}7x zqPC{SW1KGa$2c`|8a~mrExlPrjhtm0oPE#{2@KhA(y^!UkmuD4UTx<(1h()Zj(X!h zNce3r5=iRsf%C;lS{SXH*+gk@oiZTXH;C1C?IJ!%=A^#ROA%J^OF=QwjhRa2V~r%pBz4mgpSZyZ}te?g$R&Zi`N2CNqu>^PXRSeS|OXYI2++vru z$$RAv`2Y@JCq8K38m|x4cf`jvEFXqMCvkrS_gvif;9iUSUP_YtOYy^B`;J$-1}G0wZ-v-r@9$M8w!h~u2(`0{ze z$KQfGhC;`=v!iQm{bT#k#P>NqRDLN(H-UWi&kDW{@gsEVQqIz?o#i*F99p-DtxkISvBuDo>rw`~vXAK*FR`5gjFcr;TG$+3xbIF4FV}?65D1%+cP-~PDD)ij{ E5BoP_bpQYW diff --git a/java/java-usages/plugin.xml b/java/java-usages/plugin.xml index d87e2eed..e201aa45 100644 --- a/java/java-usages/plugin.xml +++ b/java/java-usages/plugin.xml @@ -6,6 +6,7 @@ + diff --git a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java index fdf037bb..d1c1e8a0 100644 --- a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java +++ b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -4,7 +4,10 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; import java.util.zip.ZipFile; import com.google.gson.*; import org.eclipse.core.resources.ResourcesPlugin; @@ -29,6 +32,8 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024; private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong(); private static final Map CACHE = new LinkedHashMap<>(); + /** One build per project at a time; waiting lookups stay cancellable. */ + private static final Map LOCKS = new ConcurrentHashMap<>(); /** Unsaved edits are covered by the working-copy digest, so reconciles never * invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and * non-Java resources, reported as F_CONTENT on a project, root or package @@ -56,6 +61,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { @Override public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { + if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args); String uri = (String) args.get(0); String key = args.get(1) instanceof String s ? s : null; int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue(); @@ -138,7 +144,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { (line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter()); } private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); } - private static IJavaProject project(String uri) { + static IJavaProject project(String uri) { Path path; try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; } IJavaProject best = null; @@ -163,7 +169,33 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { return result; } - private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + /** Builds (or reuses) the project's index in the background; its generation. */ + static long warm(IJavaProject project, IProgressMonitor monitor) throws Exception { + return index(project, monitor, true).generation; + } + + /** Warming fills free cache room only; it never evicts an index a lookup built. */ + static boolean hasWarmupCapacity(IJavaProject project) { + synchronized (CACHE) { + return CACHE.containsKey(project.getHandleIdentifier()) || (CACHE.size() < 8 && + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() < MAX_ENTRIES); + } + } + + private static Index index(IJavaProject project, IProgressMonitor monitor) throws Exception { + return index(project, monitor, false); + } + + private static Index index(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception { + ReentrantLock lock = LOCKS.computeIfAbsent(project.getHandleIdentifier(), ignored -> new ReentrantLock()); + while (!lock.tryLock(50, TimeUnit.MILLISECONDS)) check(monitor); + try { + check(monitor); + return build(project, monitor, background); + } finally { lock.unlock(); } + } + + private static Index build(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception { long revision = REVISION.get(); String id = project.getHandleIdentifier(); Map copies = new TreeMap<>(); @@ -181,7 +213,8 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)); } String workingCopies = HexFormat.of().formatHex(digest.digest()); - Index cached = CACHE.get(id); + Index cached; + synchronized (CACHE) { cached = CACHE.get(id); } if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) { boolean valid = true; for (var entry : cached.files.entrySet()) { @@ -227,11 +260,19 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { roots.add(project.getProject().getLocationURI().toString()); builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project); Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet()); - CACHE.remove(id); - while (!CACHE.isEmpty() && (CACHE.size() >= 8 || - CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) - CACHE.remove(CACHE.keySet().iterator().next()); - CACHE.put(id, result); + check(monitor); + // A warmed index that the model has already outdated is dropped, not published. + if (background && revision != REVISION.get()) throw new OperationCanceledException(); + synchronized (CACHE) { + if (background && ((!CACHE.containsKey(id) && CACHE.size() >= 8) || + CACHE.entrySet().stream().filter(e -> !e.getKey().equals(id)).mapToInt(e -> e.getValue().entries.size()).sum() + + result.entries.size() > MAX_ENTRIES)) throw new OperationCanceledException(); + CACHE.remove(id); + while (!CACHE.isEmpty() && (CACHE.size() >= 8 || + CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES)) + CACHE.remove(CACHE.keySet().iterator().next()); + CACHE.put(id, result); + } return result; } diff --git a/java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java b/java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java new file mode 100644 index 00000000..9a68f5b4 --- /dev/null +++ b/java/java-usages/src/ai/openidea/configuration/ConfigurationWarmup.java @@ -0,0 +1,111 @@ +package ai.openidea.configuration; + +import java.util.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.*; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.*; + +/** One bounded, cooperative queue per language-server process. No polling timer, + * retained ASTs, or project builds; an ordinary lookup remains the fallback. */ +final class ConfigurationWarmup { + private static final Map pending = new LinkedHashMap<>(); + private static final Set attempted = new HashSet<>(); + private static final Map completed = new LinkedHashMap<>(); + private static boolean scheduled, stopped; + private static String active; + private static final Job job = new Job("Preparing configuration navigation") { + @Override protected IStatus run(IProgressMonitor monitor) { + // Compiler defaults/import can schedule a build after ServiceReady. + // Yield to it instead of blocking a worker on the build job family. + if (building()) { + synchronized (ConfigurationWarmup.class) { + if (!stopped) schedule(2000); + else scheduled = false; + } + return Status.OK_STATUS; + } + IJavaProject project; + synchronized (ConfigurationWarmup.class) { + if (stopped || pending.isEmpty()) { scheduled = false; return Status.OK_STATUS; } + String id = pending.keySet().iterator().next(); + project = pending.remove(id); + active = id; + } + long deadline = System.nanoTime() + 30_000_000_000L; + IProgressMonitor bounded = new ProgressMonitorWrapper(monitor) { + @Override public boolean isCanceled() { + return super.isCanceled() || System.nanoTime() >= deadline || + !project.exists() || !project.getProject().isOpen(); + } + }; + try { + if (ConfigurationCommand.hasWarmupCapacity(project)) { + long build = ConfigurationCommand.warm(project, bounded); + synchronized (ConfigurationWarmup.class) { + if (completed.size() < 8) completed.put(project.getHandleIdentifier(), build); + } + } + } catch (OperationCanceledException e) { + // No partial index is published. A user lookup can retry later. + } catch (Exception e) { + org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.logException("Configuration warmup skipped", e); + } finally { + synchronized (ConfigurationWarmup.class) { + active = null; + if (!stopped && !pending.isEmpty()) schedule(200); + else scheduled = false; + } + } + return Status.OK_STATUS; + } + }; + static { job.setSystem(true); job.setPriority(Job.DECORATE); } + + private static boolean building() { + for (Object family : new Object[] { ResourcesPlugin.FAMILY_AUTO_BUILD, ResourcesPlugin.FAMILY_MANUAL_BUILD }) + for (Job other : Job.getJobManager().find(family)) + if (other.getState() != Job.NONE) return true; + return false; + } + + static synchronized Object request(List args) throws JavaModelException { + String action = args.isEmpty() ? "start" : String.valueOf(args.get(0)); + if (action.equals("cancel")) { + stopped = true; + pending.clear(); + job.cancel(); + if (active == null) scheduled = false; + } else if (action.equals("start") && !stopped) { + // Open Java buffers identify the user's active modules. Keep those + // ahead of the rest when the workspace exceeds the cache budget. + Set open = new HashSet<>(); + for (ICompilationUnit unit : JavaCore.getWorkingCopies(null)) open.add(unit.getJavaProject().getHandleIdentifier()); + for (ICompilationUnit unit : JavaCore.getWorkingCopies( + org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) + open.add(unit.getJavaProject().getHandleIdentifier()); + Set preferred = new HashSet<>(); + if (args.size() > 1 && args.get(1) instanceof List uris) { + for (Object uri : uris) if (uri instanceof String text) { + IJavaProject project = ConfigurationCommand.project(text); + if (project != null) preferred.add(project.getHandleIdentifier()); + } + } + List projects = new ArrayList<>(Arrays.asList( + JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects())); + projects.sort(Comparator.comparing((IJavaProject p) -> !preferred.contains(p.getHandleIdentifier())) + .thenComparing(p -> !open.contains(p.getHandleIdentifier())) + .thenComparing(IJavaProject::getElementName)); + for (IJavaProject project : projects) { + String id = project.getHandleIdentifier(); + if (project.getProject().isOpen() && !attempted.contains(id) && pending.size() < 64) { + attempted.add(id); + pending.put(id, project); + } + } + if (!scheduled && !pending.isEmpty()) { scheduled = true; job.schedule(2000); } + } + return Map.of("scheduled", scheduled, "pending", pending.size(), "active", active == null ? "" : active, + "completed", new LinkedHashMap<>(completed)); + } +} diff --git a/java/java-usages/tests/configuration.py b/java/java-usages/tests/configuration.py index f218019f..f1e09745 100644 --- a/java/java-usages/tests/configuration.py +++ b/java/java-usages/tests/configuration.py @@ -159,11 +159,28 @@ try: send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}}) request('java/buildWorkspace', True) assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init + def warmup(action='start'): + return request('workspace/executeCommand', {'command': 'openidea.java.configuration.warmup', 'arguments': [action, [(project / 'application.yml').as_uri()]]}) + # Queuing is cheap and idempotent; the index is built by a background job. + queued_at = time.perf_counter() + queued = warmup() + assert queued['scheduled'] and queued['pending'] > 0 and not queued['completed'], queued + assert warmup()['pending'] == queued['pending'], 'a repeated start queued the project twice' + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + warmed = warmup('status') + if not warmed['scheduled']: break + time.sleep(.1) + assert not warmed['scheduled'] and warmed['completed'], warmed + assert warmup()['pending'] == 0, 'a repeated start queued a warmed project again' + prewarm_seconds = time.perf_counter() - queued_at results = {} for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']: start = time.perf_counter() result = query(key) results[key] = {'seconds': time.perf_counter() - start, 'response': result} + if key == 'app': + assert result['generation'] in warmed['completed'].values(), ('the first lookup rebuilt the warmed index', result, warmed) assert result['locations'], (key, result) attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']}) assert attached and 'LibrarySettings' in attached and 'port' in attached, attached @@ -238,7 +255,9 @@ try: send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}}) restored = query('app.nested.url') assert restored['locations'], restored - evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]} + cancelled = warmup('cancel') + assert not cancelled['scheduled'] and cancelled['pending'] == 0, cancelled + evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'prewarmSeconds': prewarm_seconds, 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]} (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) print(json.dumps(evidence, indent=2)) finally: diff --git a/knip.ts b/knip.ts index 98b47d66..9e9e6dec 100644 --- a/knip.ts +++ b/knip.ts @@ -1,8 +1,9 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { - // Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins. - entry: ["e2e/pythonHighlight.fixture.ts"], + // Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins; + // standalone browser fixtures are loaded by their own HTML pages. + entry: ["e2e/*.fixture.{ts,tsx}"], project: [ "src/**/*.{ts,tsx,css}", "!src/editor/core/cm/angular/parser.terms.ts", diff --git a/src/editor/lsp/configurationNavigation.test.ts b/src/editor/lsp/configurationNavigation.test.ts new file mode 100644 index 00000000..8a790570 --- /dev/null +++ b/src/editor/lsp/configurationNavigation.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { commands, Location } from "@/platform/ipc"; + +const lspGoto = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } }; +}); +const { emitLocalIdeEvent } = await import("@/platform/ipc"); +const { _emitEditorEvent } = await import("@/editor/api"); +const { lspDoc } = await import("./lsp"); +const { navigationExtension } = await import("./navigation"); +const { requestNavigation, invalidateConfigurationNavigation, installConfigurationNavigationEvents, navigationWord } = + await import("./configurationNavigation"); + +const target: Location = { + path: "/p/Config.java", + uri: null, + range: { start: { line: 3, col: 4 }, end: { line: 3, col: 9 } }, +}; +const views: EditorView[] = []; +function editor(doc = 1, language = "yaml", text = "server:\n port: 8080\n") { + const view = new EditorView({ + state: EditorState.create({ + doc: text, + extensions: [ + lspDoc.of({ doc, path: `/p/${doc}.${language}`, language, uiMode: language, readOnly: false }), + navigationExtension(), + ], + }), + parent: document.body, + }); + views.push(view); + return view; +} +let off: () => void; +beforeEach(() => { + vi.useFakeTimers(); + invalidateConfigurationNavigation(); + lspGoto.mockReset().mockResolvedValue({ status: "ok", data: [target] }); + off = installConfigurationNavigationEvents(); +}); +afterEach(() => { + off(); + views.splice(0).forEach((v) => v.destroy()); + vi.useRealTimers(); +}); + +describe("configuration navigation result reuse", () => { + it.each(["yaml", "properties"])("%s names include separators but exclude syntax and adjacent values", (language) => { + const view = editor(1, language, ' "spring.datasource.driver-class-name": example.Driver'); + for (const offset of [3, 9, 20, 26, 32, 35]) { + const range = navigationWord(view, offset)!; + expect(view.state.sliceDoc(range.from, range.to)).toBe("spring.datasource.driver-class-name"); + } + expect(navigationWord(view, 1)).toBeNull(); + expect(navigationWord(view, 39)).toBeNull(); + }); + + it("shares an in-flight hover lookup with a click anywhere in the same key", async () => { + let resolve!: (r: Awaited>) => void; + lspGoto.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }), + ); + const view = editor(); + const hover = requestNavigation(view, 10, "definition"); + const click = requestNavigation(view, 12, "definition"); + expect(click).toBe(hover); + await vi.waitFor(() => expect(lspGoto).toHaveBeenCalledOnce()); + resolve({ status: "ok", data: [target] }); + expect(await click).toEqual([target]); + expect(await requestNavigation(view, 11, "definition")).toEqual([target]); + expect(lspGoto).toHaveBeenCalledOnce(); + }); + + it("reuses results when the same document is shown again", async () => { + const view = editor(); + await requestNavigation(view, 10, "definition"); + await requestNavigation(editor(), 11, "definition"); + expect(lspGoto).toHaveBeenCalledOnce(); + }); + + it("keeps different keys and documents separate", async () => { + const view = editor(); + await requestNavigation(view, 1, "definition"); + await requestNavigation(view, 10, "definition"); + await requestNavigation(editor(2), 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(3); + }); + + it.each(["java", "typescript"])("leaves %s native navigation uncached", async (language) => { + const view = editor(1, language, "value"); + await requestNavigation(view, 1, "definition"); + await requestNavigation(view, 1, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it("does not mix definition with other navigation kinds", async () => { + const view = editor(); + await requestNavigation(view, 10, "definition"); + await requestNavigation(view, 10, "typeDefinition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it("invalidates on edits to another open Java document, before its IPC sync", async () => { + const view = editor(); + const java = editor(2, "java", "class Config {}"); + await requestNavigation(view, 10, "definition"); + java.dispatch({ changes: { from: 6, to: 12, insert: "Changed" } }); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it.each([ + { type: "fsChanged", created: [], modified: ["/p/Config.java"], removed: [] }, + { type: "fsChanged", created: [], modified: [], removed: ["/p/Config.java"] }, + { type: "docPatched", doc: 2, baseVersion: 1, version: 2, changes: [] }, + { type: "languageServer", server: 1, name: "jdtls", status: "starting", message: null }, + { type: "lspRefresh", server: 1, kind: "codeLens" }, + { type: "excludedDirsChanged", dirs: ["/p/target"] }, + ] as const)("invalidates on $type", async (event) => { + const view = editor(); + await requestNavigation(view, 10, "definition"); + emitLocalIdeEvent(event as Parameters[0]); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it("does not invalidate just because navigation opened a clean target", async () => { + const view = editor(); + await requestNavigation(view, 10, "definition"); + _emitEditorEvent({ type: "docOpened", doc: 2, path: target.path, language: "java" }); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledOnce(); + _emitEditorEvent({ type: "docClosed", doc: 2, path: target.path }); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it("discards in-flight results when dependencies change", async () => { + let resolve!: (r: Awaited>) => void; + lspGoto.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }), + ); + const view = editor(); + const pending = requestNavigation(view, 10, "definition"); + const rejected = expect(pending).rejects.toMatchObject({ kind: "cancelled" }); + await vi.waitFor(() => expect(lspGoto).toHaveBeenCalledOnce()); + invalidateConfigurationNavigation(); + resolve({ status: "ok", data: [target] }); + await rejected; + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it.each(["settled", "refresh"])("does not swallow an in-flight click on its own server %s event", async (event) => { + let resolve!: (r: Awaited>) => void; + lspGoto.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }), + ); + const view = editor(); + const pending = requestNavigation(view, 10, "definition"); + await vi.waitFor(() => expect(lspGoto).toHaveBeenCalledOnce()); + if (event === "settled") { + emitLocalIdeEvent({ type: "languageServer", server: 1, name: "jdtls", status: "busy", message: null }); + vi.setSystemTime(Date.now() + 1600); + emitLocalIdeEvent({ type: "languageServer", server: 1, name: "jdtls", status: "ready", message: null }); + } else emitLocalIdeEvent({ type: "lspRefresh", server: 1, kind: "codeLens" }); + resolve({ status: "ok", data: [target] }); + expect(await pending).toEqual([target]); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it("does not retain errors or missing targets during import", async () => { + const view = editor(); + lspGoto.mockResolvedValueOnce({ status: "error", error: { kind: "protocol", message: "timeout" } }); + await expect(requestNavigation(view, 10, "definition")).rejects.toThrow("timeout"); + lspGoto.mockResolvedValueOnce({ status: "ok", data: [] }); + expect(await requestNavigation(view, 10, "definition")).toEqual([]); + expect(await requestNavigation(view, 10, "definition")).toEqual([target]); + expect(lspGoto).toHaveBeenCalledTimes(3); + }); + + it("expires results when a server did not report a change", async () => { + const view = editor(); + await requestNavigation(view, 10, "definition"); + vi.setSystemTime(Date.now() + 30_001); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); + + it("bounds retained entries", async () => { + const view = editor(1, "properties", Array.from({ length: 130 }, (_, i) => `key${i}=x\n`).join("")); + for (let i = 1; i <= 130; i++) await requestNavigation(view, view.state.doc.line(i).from, "definition"); + await requestNavigation(view, 0, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(131); + }); + + it("bounds retained locations even for large multi-target answers", async () => { + const view = editor(); + lspGoto.mockResolvedValueOnce({ status: "ok", data: Array.from({ length: 4097 }, () => target) }); + await requestNavigation(view, 10, "definition"); + await requestNavigation(view, 10, "definition"); + expect(lspGoto).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/editor/lsp/configurationNavigation.ts b/src/editor/lsp/configurationNavigation.ts new file mode 100644 index 00000000..cac33236 --- /dev/null +++ b/src/editor/lsp/configurationNavigation.ts @@ -0,0 +1,160 @@ +/** Shared, bounded navigation results for YAML/properties hover and click. */ +import type { Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { onEditorEvent } from "@/editor/api"; +import { commands, IpcError, onIdeEvent, type GotoKind, type Location } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { onServerSettled } from "./availability"; +import { docOf, lspRequest } from "./lsp"; +import { offsetToPos } from "./positions"; + +// Events provide invalidation; the age limit is a backstop for servers/remote +// file systems which do not report every semantic change. Empty/error responses +// are never retained, since import may still be in progress. +export const CONFIGURATION_NAVIGATION_MAX_AGE = 30_000; +const MAX_ENTRIES = 128; +const MAX_LOCATIONS = 4096; +interface Cached { + text: Text; + path: string; + at: number; + size: number; + retain: boolean; + pending: Promise; +} +const results = new Map(); +let revision = 0; + +export function configurationNavigationRevision(): number { + return revision; +} + +export function invalidateConfigurationNavigation() { + revision++; + results.clear(); +} + +export function isConfigurationDocument(view: EditorView): boolean { + const language = docOf(view)?.language; + return language === "yaml" || language === "properties"; +} + +/** Configuration names include hyphens and dots even when the editor's + * language word rules split them. Share this range between decoration and lookup. */ +export function navigationWord(view: EditorView, offset: number): { from: number; to: number } | null { + if (!isConfigurationDocument(view)) return view.state.wordAt(offset); + const line = view.state.doc.lineAt(offset); + const nameChar = /[\p{L}\p{N}_.$-]/u; + let from = offset - line.from; + let to = from; + while (from > 0 && nameChar.test(line.text[from - 1]!)) from--; + while (to < line.text.length && nameChar.test(line.text[to]!)) to++; + return from < to ? { from: line.from + from, to: line.from + to } : null; +} + +/** Indexing/refresh can be progress from this very lookup. Do not cancel an + * explicit click when its own server task finishes; simply don't retain that + * answer. Source edits/restarts still invalidate the request generation. */ +function refreshConfigurationNavigation() { + for (const [key, entry] of results) { + entry.retain = false; + if (entry.size) results.delete(key); + } +} + +export function hasConfigurationNavigation(view: EditorView, offset: number): boolean { + const doc = docOf(view); + const word = navigationWord(view, offset); + if (!doc || !word) return false; + const entry = results.get(`${doc.doc}:${word.from}:${word.to}`); + return ( + !!entry && + entry.size > 0 && + entry.retain && + entry.path === doc.path && + entry.text.eq(view.state.doc) && + Date.now() - entry.at < CONFIGURATION_NAVIGATION_MAX_AGE + ); +} + +export function requestNavigation(view: EditorView, offset: number, kind: GotoKind): Promise { + const doc = docOf(view)!; + const word = navigationWord(view, offset); + if (!isConfigurationDocument(view) || kind !== "definition" || !word) + return lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, offset), kind)); + const key = `${doc.doc}:${word.from}:${word.to}`; + const text = view.state.doc; + const hit = results.get(key); + if ( + hit && + hit.path === doc.path && + hit.text.eq(text) && + Date.now() - hit.at < (hit.size ? CONFIGURATION_NAVIGATION_MAX_AGE : 3000) + ) { + results.delete(key); + results.set(key, hit); + return hit.pending; + } + const version = revision; + const entry: Cached = { + text, + path: doc.path, + at: Date.now(), + size: 0, + retain: true, + pending: lspRequest( + () => commands.lspGoto(doc.doc, offsetToPos(text, word.from), kind), + () => revision === version, + ) + .then((locations) => { + if (revision !== version || !view.state.doc.eq(text)) + throw new IpcError({ kind: "cancelled", message: "cancelled" }); + entry.size = locations.length; + if ((!locations.length || !entry.retain) && results.get(key) === entry) results.delete(key); + let size = 0; + for (const value of results.values()) size += value.size; + while (size > MAX_LOCATIONS && results.size) { + const oldest = results.keys().next().value!; + size -= results.get(oldest)!.size; + results.delete(oldest); + } + return locations; + }) + .catch((error: unknown) => { + if (results.get(key) === entry) results.delete(key); + throw error; + }), + }; + results.delete(key); + results.set(key, entry); + while (results.size > MAX_ENTRIES) results.delete(results.keys().next().value!); + return entry.pending; +} + +export function installConfigurationNavigationEvents(): () => void { + const clear = invalidateConfigurationNavigation; + const off = [ + onIdeEvent("fsChanged", clear), + onIdeEvent("excludedDirsChanged", clear), + onIdeEvent("docPatched", clear), + onIdeEvent("docLanguage", clear), + onIdeEvent("workspaceEditApplied", clear), + onIdeEvent("lspRefresh", refreshConfigurationNavigation), + onIdeEvent("languageServer", (e) => { + if (e.status === "starting" || e.status === "stopped" || e.status === "crashed") clear(); + }), + onIdeEvent("languageServerRemoved", clear), + onServerSettled(refreshConfigurationNavigation), + onEditorEvent((e) => { + // Closing an unsaved Java buffer restores its on-disk declarations. + if (e.type === "docClosed" || e.type === "docSaved") clear(); + }), + useProject.subscribe((s, previous) => { + if (s.project?.root !== previous.project?.root) clear(); + }), + ]; + return () => { + off.forEach((dispose) => dispose()); + clear(); + }; +} diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 8816b715..2b7a5b00 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -27,6 +27,7 @@ import { inlineCompletionExtension, registerInlineCompletionActions } from "./in import { semanticExtension } from "./semantic"; import { usagesLensExtension } from "./codeLens"; import { autoImportExtension } from "./autoImport"; +import { installConfigurationNavigationEvents } from "./configurationNavigation"; import { installAvailabilityEvents } from "./availability"; import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from "./actions"; import { lspTheme } from "./theme"; @@ -69,6 +70,7 @@ export function activate(): () => void { const disposers = [ installDiagnosticsEvents(), installAvailabilityEvents(), + installConfigurationNavigationEvents(), registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }), // After the run icons (order 55), as their own gutter column. registerEditorContribution({ id: "lsp.lineMarkers", order: 56, extensions: implementationGutter }), diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index ddf811eb..f35a5911 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -78,6 +78,15 @@ import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap"; import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier"; import { create } from "zustand"; import { tx } from "@/platform/i18n"; +import { + CONFIGURATION_NAVIGATION_MAX_AGE, + configurationNavigationRevision, + navigationWord, + hasConfigurationNavigation, + invalidateConfigurationNavigation, + isConfigurationDocument, + requestNavigation, +} from "./configurationNavigation"; const highlightsBackoff = featureBackoff(); @@ -157,7 +166,7 @@ function searchingTitle(name: string | null, count: number): string { } function nameAt(view: EditorView, offset: number): string | null { - const word = view.state.wordAt(offset); + const word = navigationWord(view, offset); return word ? view.state.sliceDoc(word.from, word.to) : null; } @@ -285,9 +294,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) const done = showPendingHint(view, tx`Finding navigation target…`); const stream = kind === "implementation" ? streamedImplementations(view, offset, done) : null; streamed = stream; - const request = stream - ? () => stream.request(doc.doc, pos) - : () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind)); + const request = stream ? () => stream.request(doc.doc, pos) : () => requestNavigation(view, offset, kind); try { locs = dedupeLocations(await gotoAfterEdits(view, request)); } finally { @@ -481,7 +488,7 @@ function linkAt(view: EditorView, x: number, y: number): { from: number; to: num const links = knownLinks(view); const ref = (links && linkReference(view.state.doc, links, pos)) ?? textReference(view.state.doc, pos); if (ref) return { from: ref.from, to: ref.to, link: true }; - const word = view.state.wordAt(pos); + const word = navigationWord(view, pos); return word && word.from < word.to ? { from: word.from, to: word.to, link: false } : null; } @@ -500,7 +507,7 @@ async function probeNavigation( if (!navigable && doc) { const pos = offsetToPos(view.state.doc, offset); try { - navigable = (await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"))).length > 0; + navigable = (await requestNavigation(view, offset, "definition")).length > 0; } catch (e) { if (!isUnavailable(e)) throw e; navigable = !!(await gotoFallback(view, doc, pos))?.length; @@ -516,7 +523,11 @@ const linkPlugin = ViewPlugin.fromClass( class { last: { x: number; y: number } | null = null; /** Probe results per word (`from:to`) for the current document. */ - private probed = new Map(); + private probed = new Map< + string, + { result: { info: NavInfo | null; plugin?: NavigationMatch } | null; at: number } + >(); + private revision = configurationNavigationRevision(); private probedDoc: Text | null = null; private providerEpoch = -1; private probing: string | null = null; @@ -598,16 +609,34 @@ const linkPlugin = ViewPlugin.fromClass( this.set({ from: target.from, to: target.to, info: null }); return; } - if (this.probedDoc !== view.state.doc || this.providerEpoch !== navigationEpoch()) { + if ( + this.probedDoc !== view.state.doc || + this.providerEpoch !== navigationEpoch() || + this.revision !== configurationNavigationRevision() + ) { this.providerEpoch = navigationEpoch(); + this.revision = configurationNavigationRevision(); + this.probeVersion++; + clearTimeout(this.probeTimer); + this.probing = null; this.probed.clear(); this.probedDoc = view.state.doc; } const key = `${target.from}:${target.to}`; - if (this.probed.has(key)) { - const hit = this.probed.get(key); + const hit = this.probed.get(key); + // Configuration targets appear as the Java import progresses: they expire, and a + // remembered target must still be in the shared results. Other results last until an edit. + const config = isConfigurationDocument(view); + if ( + hit && + (!config || Date.now() - hit.at < (hit.result ? CONFIGURATION_NAVIGATION_MAX_AGE : 1000)) && + (!hit.result || !config || hasConfigurationNavigation(view, target.from)) + ) { + const found = hit.result; this.set( - hit ? { from: hit.plugin?.from ?? target.from, to: hit.plugin?.to ?? target.to, info: hit.info } : null, + found + ? { from: found.plugin?.from ?? target.from, to: found.plugin?.to ?? target.to, info: found.info } + : null, ); return; } @@ -626,28 +655,34 @@ const linkPlugin = ViewPlugin.fromClass( }, 3000); const docAt = view.state.doc; const epochAt = navigationEpoch(); + const revision = this.revision; void probeNavigation(view, target.from) .then((result) => { if ( this.disposed || view.state.doc !== docAt || this.probeVersion !== version || - epochAt !== navigationEpoch() + epochAt !== navigationEpoch() || + revision !== configurationNavigationRevision() ) return; clearTimeout(this.probeTimer); - this.probed.set(key, result); + if (this.probed.size >= 128) this.probed.delete(this.probed.keys().next().value!); + this.probed.set(key, { result, at: Date.now() }); if (this.probing !== key) return; this.probing = null; + if (!result) this.set(null); if (result) { this.set({ from: result.plugin?.from ?? target.from, to: result.plugin?.to ?? target.to, info: null }); - if (result.plugin) return; + // Configuration navigation is a link gesture. Plain hover still + // shows schema/type documentation; do not cover the key with it here. + if (result.plugin || isConfigurationDocument(view)) return; // Documentation must never delay the link cursor or underline. void requestHover(view, target.from) .then((hover) => { if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version || !hover) return; const info = declarationSummary(hover.contents); - this.probed.set(key, { info }); + this.probed.set(key, { result: { info }, at: Date.now() }); this.set({ from: target.from, to: target.to, info }); }) .catch(() => {}); @@ -891,7 +926,10 @@ const highlightPlugin = ViewPlugin.fromClass( export function navigationExtension(): Extension { return [ EditorView.updateListener.of((u) => { - if (u.docChanged) editedAt.set(u.view, performance.now()); + if (u.docChanged) { + editedAt.set(u.view, performance.now()); + invalidateConfigurationNavigation(); + } }), linkField, linkPlugin, diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 07aaa304..752320bf 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -45,6 +45,7 @@ const fb = await import("./syntaxFallback"); const { gotoTargets, navigationExtension, highlightUsages } = await import("./navigation"); const { startRename, renameNamePart } = await import("./rename"); const { lspDoc } = await import("./lsp"); +const { invalidateConfigurationNavigation } = await import("./configurationNavigation"); const { useLspPopup, closePopup } = await import("./popupStore"); const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } }); @@ -96,6 +97,7 @@ function editor(doc: string, language = "typescript", anchor = 0, path = "/p/a.t } beforeEach(() => { + invalidateConfigurationNavigation(); for (const f of Object.values(cmd)) f.mockClear(); openFile.mockClear(); closePopup(); @@ -603,6 +605,79 @@ describe("modifier-hover navigation recovery", () => { ); } + it.each(["yaml", "properties"])( + "%s hover underlines without a popup and clicking reuses the target", + async (language) => { + cmd.lspGoto.mockReturnValueOnce(ok([target])); + const view = editor("port: 8080", language); + move(view, 1); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("port")); + expect(cmd.lspHover).not.toHaveBeenCalled(); + view.contentDOM.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + metaKey: mac, + ctrlKey: !mac, + }), + ); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce()); + expect(cmd.lspGoto).toHaveBeenCalledOnce(); + expect(view.dom.querySelector(".cm-lsp-nav-info")).toBeNull(); + }, + ); + + it.each(["yaml", "properties"])("%s underlines the entire hyphenated configuration name", async (language) => { + cmd.lspGoto.mockReturnValueOnce(ok([target])); + const view = editor("driver-class-name: example.Driver", language); + for (const pos of [1, 6, 8, 12, 15]) { + move(view, pos); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("driver-class-name")); + } + await gotoTargets(view, "definition", 14); + expect(cmd.lspGoto).toHaveBeenCalledOnce(); + expect(openFile).toHaveBeenCalledOnce(); + }); + + it("invalidates the hover marker after dependencies change", async () => { + cmd.lspGoto.mockReturnValueOnce(ok([target])); + const view = editor("port: 8080", "yaml"); + move(view, 1); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")).not.toBeNull()); + invalidateConfigurationNavigation(); + cmd.lspGoto.mockReturnValueOnce(ok([])); + move(view, 1); + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")).toBeNull()); + expect(cmd.lspGoto).toHaveBeenCalledTimes(2); + }); + + it("retries a stuck configuration probe without retaining the late response", async () => { + let finish!: (value: Awaited>) => void; + cmd.lspGoto.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = editor("port: 8080", "yaml"); + vi.useFakeTimers(); + try { + move(view, 1); + await vi.advanceTimersByTimeAsync(3001); + cmd.lspGoto.mockReturnValueOnce(ok([target])); + move(view, 1); + await vi.advanceTimersByTimeAsync(0); + expect(cmd.lspGoto).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-link")).not.toBeNull()); + finish({ status: "ok", data: [] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + await gotoTargets(view, "definition", 1); + expect(cmd.lspGoto).toHaveBeenCalledTimes(2); + expect(openFile).toHaveBeenCalledOnce(); + }); + it("shows the link without waiting for documentation", async () => { cmd.lspGoto.mockReturnValueOnce(ok([target])); cmd.lspHover.mockReturnValueOnce(new Promise(() => {}));