diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index d9d6e4b60..4a7d00051 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1475,7 +1475,20 @@ impl LspManager { }; let native = self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial); - let (configuration, result) = futures::join!(configuration, native); + futures::pin_mut!(configuration, native); + let (configuration, result) = match futures::future::select(configuration, native).await { + futures::future::Either::Left((configuration, native)) => (configuration, native.await), + futures::future::Either::Right((result, configuration)) => { + // The configuration index is rebuilt after Java changes, which takes seconds in a + // project with many dependencies: the usages found meanwhile are shown first. + if let (Some(partial), Ok(found)) = (partial, &result) + && !found.locations.is_empty() + { + partial(found.clone()); + } + (configuration.await, result) + } + }; let configuration = configuration?; let mut result = result?; if let Some(configuration) = configuration { diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 111348dd5..052ba7975 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -4128,6 +4128,52 @@ async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_ assert_eq!(refs.incomplete, None); } +/// The configuration index is rebuilt after Java changes, which can take seconds: Show Usages +/// shows the Java usages meanwhile, then adds the configuration ones. +#[tokio::test(flavor = "multi_thread")] +async fn java_usages_arrive_before_configuration_usages() { + let mut caps = incremental_caps(); + caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages", "openidea.java.configuration"]}); + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let yaml = h.root.join("application.yml"); + std::fs::write(&yaml, "app:\n target: 1\n").unwrap(); + let property = json!({"uri": uri_of(&yaml), "range": { + "start": {"line": 1, "character": 2}, "end": {"line": 1, "character": 8}}}); + let gate = Arc::new(Gate::default()); + let held = gate.clone(); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + (method == "workspace/executeCommand").then(|| { + if params["command"] == "openidea.java.configuration" { + held.hold(); + return json!({"locations": [property.clone()], "keys": [{"name": "app.target", "relaxed": true}], "roots": []}); + } + let docs = s.docs.lock(); + let rows = docs.iter().flat_map(|(uri, text)| occurrences(uri, text)); + Value::Array(rows.map(|location| json!({"location": location, "kind": "unknown"})).collect()) + }) + })); + let (doc, a) = h.open("a.java", "int target;"); + h.wait_status("jdtls", ServerStatus::Ready).await; + eventually(|| s.docs.lock().len() == 1).await; + let partials = Mutex::new(Vec::::new()); + let push = |r| partials.lock().push(r); + let search = h.mgr.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), Some(&push)); + let release = async { + eventually(|| !partials.lock().is_empty()).await; + assert!(gate.entered(), "shown before the configuration lookup started"); + gate.open(); + }; + let (refs, ()) = tokio::join!(search, release); + let refs = refs.unwrap(); + let partials = partials.into_inner(); + assert_eq!(partials.len(), 1); + let paths = |r: &References| r.locations.iter().map(|l| l.path.to_path_buf()).collect::>(); + assert_eq!(paths(&partials[0]), std::slice::from_ref(&a)); + assert_eq!(partials[0].classified.len(), 1); + assert_eq!(paths(&refs), [a, yaml]); +} + #[tokio::test(flavor = "multi_thread")] async fn configuration_extension_failure_preserves_native_java_navigation() { let mut caps = incremental_caps(); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 34c70b099..18c513b38 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1202,7 +1202,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.7.0.jar"); + let extension = data_dir.join("java-usages-1.8.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/java/java-usages/CONFIGURATION.md b/java/java-usages/CONFIGURATION.md index ef12eb1c3..24f49b50f 100644 --- a/java/java-usages/CONFIGURATION.md +++ b/java/java-usages/CONFIGURATION.md @@ -78,7 +78,13 @@ changes; a fingerprint of working copies with unsaved changes covers edits befor they are saved. Opening or closing an editor, reconciling, building, and non-Java resource changes (configuration files are parsed by the client; metadata files are checked by timestamp) keep it. This is **snapshot invalidation**, not a fully -incremental Java semantic index. +incremental Java semantic index. A rebuild reuses each dependency JAR's metadata +associations (up to 100,000 in all) while the JAR's timestamp and size and the project's +classpath are unchanged: resolving a Spring Boot JAR's thousands of properties to library +members takes seconds, and a source change cannot affect them. + +Find Usages shows the Java usages as soon as they are found, then adds the configuration +ones when a rebuild of the snapshot takes longer. 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 diff --git a/java/java-usages/MANIFEST.MF b/java/java-usages/MANIFEST.MF index cdcb4bef1..40fc4e42f 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.7.0 +Bundle-Version: 1.8.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 0fde82683..d705841d3 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -69,7 +69,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.7.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.8.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. @@ -103,6 +103,10 @@ Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches eac file's decompiled source on its own and returns the line (`text`) of each usage in a library class; its reproducible SHA-256 is `56d35619d5be937b028ac143ba748365a66a74c803bc112e4788a7dce98b44fc`. +Version 1.8.0 keeps each dependency JAR's configuration metadata across rebuilds of the +configuration index (until the JAR or the project's classpath changes), so a source change no +longer resolves every Spring Boot property again; its reproducible SHA-256 is +`728ab33b5e01fa5e496818f25ddbc487458a92339026899b0e22abda5adfd67b`. ```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 f637e1e40..693009b7f 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/src/ai/openidea/configuration/ConfigurationCommand.java b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java index d1c1e8a05..3a16024d4 100644 --- a/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java +++ b/java/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -41,7 +41,41 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { * own stamps), do not change the index either. */ private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY | IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED; - static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); } + /** Entries read from each library's Spring metadata, by project and archive. They depend + * on the archive and the project's classpath only, so a source change rebuilds the index + * without reading every dependency's metadata again: resolving its thousands of properties + * to library members takes seconds. A changed archive stamp or classpath drops them. */ + private static final Map ARCHIVES = new HashMap<>(); + private record Archive(FileStamp stamp, List entries) {} + static { + JavaCore.addElementChangedListener(event -> { + if (affects(event.getDelta())) REVISION.incrementAndGet(); + if (classpathChanged(event.getDelta())) synchronized (ARCHIVES) { ARCHIVES.clear(); } + }, ElementChangedEvent.POST_CHANGE); + } + /** A project or package fragment root added, removed or reconfigured (not a change of + * the sources or resources inside it). */ + static boolean classpathChanged(IJavaElementDelta delta) { + if (delta.getElement().getElementType() > IJavaElement.PACKAGE_FRAGMENT_ROOT) return false; + int flags = delta.getFlags() & ~(IGNORED_FLAGS | IJavaElementDelta.F_CONTENT); + if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true; + for (IJavaElementDelta child : delta.getAffectedChildren()) if (classpathChanged(child)) return true; + return false; + } + private static List archive(String key, FileStamp stamp) { + synchronized (ARCHIVES) { + Archive known = ARCHIVES.get(key); + return known != null && known.stamp.equals(stamp) ? known.entries : null; + } + } + private static void remember(String key, FileStamp stamp, List entries) { + synchronized (ARCHIVES) { + ARCHIVES.remove(key); + if (ARCHIVES.values().stream().mapToInt(known -> known.entries.size()).sum() + entries.size() > MAX_ENTRIES) + ARCHIVES.clear(); + if (entries.size() <= MAX_ENTRIES) ARCHIVES.put(key, new Archive(stamp, entries)); + } + } static boolean affects(IJavaElementDelta delta) { int flags = delta.getFlags() & ~IGNORED_FLAGS; if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT; @@ -465,8 +499,18 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { Path path = root.getPath().toFile().toPath(); if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null) path = root.getResource().getLocation().toFile().toPath(); - files.put(path, stamp(path)); + FileStamp stamp = stamp(path); + files.put(path, stamp); if (!Files.isRegularFile(path)) return; + String key = project.getHandleIdentifier() + '\n' + path; + List known = archive(key, stamp); + if (known != null && entries.size() + known.size() <= MAX_ENTRIES) { + entries.addAll(known); + return; + } + int start = entries.size(); + boolean before = incomplete; + incomplete = false; try (ZipFile zip = new ZipFile(path.toFile())) { for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) { var entry = zip.getEntry("META-INF/" + filename); @@ -481,6 +525,9 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { metadataObject(metadata, project); } } catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; } + // Only a complete reading is reused; an incomplete one is read again next time. + if (!incomplete) remember(key, stamp, List.copyOf(entries.subList(start, entries.size()))); + incomplete |= before; } void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException { JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray(); diff --git a/java/java-usages/tests/configuration.py b/java/java-usages/tests/configuration.py index f1e09745b..edcddba66 100644 --- a/java/java-usages/tests/configuration.py +++ b/java/java-usages/tests/configuration.py @@ -244,6 +244,14 @@ try: while not query('added.size')['locations']: assert time.monotonic() < deadline, 'a new source on disk did not reach the index' time.sleep(0.2) + # That rebuild reused the library's metadata; a changed archive is read again. + assert query('vendor.port')['locations'], 'a source rebuild lost the library metadata' + metadata['properties'].append({'name': 'vendor.speed', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'}) + with zipfile.ZipFile(library, 'w') as z: + z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class') + z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata)) + assert query('vendor.speed')['locations'], 'a changed library archive kept its old metadata' + assert query('vendor.port')['locations'] # Unsaved changes must replace old keys, and closing must restore disk keys. changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}') send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})