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 8208527b..47428a1e 100644 Binary files a/java/java-usages/openidea-java-usages.jar and b/java/java-usages/openidea-java-usages.jar differ 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(() => {}));