fix(java): stop Show Usages waiting seconds on the configuration index after a change

After any Java change, the configuration index was rebuilt from scratch on the next Show
Usages, resolving every dependency's Spring metadata (thousands of Spring Boot properties)
to library members again, and Show Usages waited for it: 15-20 seconds in a Spring Boot +
MyBatis project.

- The Java extension (1.8.0) keeps each dependency JAR's metadata entries across rebuilds
  until the JAR's timestamp/size or the project's classpath changes.
- Show Usages shows the Java usages as soon as they are found and adds the configuration
  ones when the index takes longer.

Closes #1898
This commit is contained in:
l0ng-ai committed 2026-11-07 22:35:10 +08:00
1 parent f96ca468a8
commit c0717358df
9 files changed
+131 -7

No files matched your search

+7 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+5 -1
View File
@@ -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
Binary file not shown.
@@ -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<String, Archive> ARCHIVES = new HashMap<>();
private record Archive(FileStamp stamp, List<Entry> 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<Entry> 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<Entry> 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<Entry> 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();
+8
View File
@@ -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}]}})