mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
fix(java): stop Show Usages waiting seconds on the configuration index after a change (#2011)
Usages counts clicked after a Java change waited for the configuration index to resolve every dependency's Spring metadata again (15–20 s in a Spring Boot project). The Java extension (1.8.0) now keeps each dependency JAR's metadata across rebuilds until the JAR or the classpath changes, and Show Usages shows the Java usages first, adding the configuration ones when the index takes longer. Closes #1898
This commit is contained in:
1 parent
112ed773fb
commit
d509ed6e81
9 files changed
+131
-7
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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::<References>::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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}]}})
|
||||
|
||||
Reference in new issue
Block a user