diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 9120b0e5..623a8705 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1173,7 +1173,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.2.0.jar"); + let extension = data_dir.join("java-usages-1.2.1.jar"); let bytes = include_bytes!("../../../plugins/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/plugins/java-usages/CONFIGURATION.md b/plugins/java-usages/CONFIGURATION.md index a696e1cd..f0965bcd 100644 --- a/plugins/java-usages/CONFIGURATION.md +++ b/plugins/java-usages/CONFIGURATION.md @@ -57,10 +57,13 @@ ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go t 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/reconcile changes -invalidate it conservatively, including dependent-type changes; open working-copy -content fingerprints also prevent stale answers before reconciliation. This is -**snapshot invalidation**, not a fully incremental Java semantic index. +semantically. 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 +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. 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 diff --git a/plugins/java-usages/MANIFEST.MF b/plugins/java-usages/MANIFEST.MF index 7ec5b7f3..2d5796c3 100644 --- a/plugins/java-usages/MANIFEST.MF +++ b/plugins/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.2.0 +Bundle-Version: 1.2.1 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/plugins/java-usages/README.md b/plugins/java-usages/README.md index 6f820321..819fd2b3 100644 --- a/plugins/java-usages/README.md +++ b/plugins/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.2.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.2.1.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. @@ -70,6 +70,9 @@ and compare SHA-256. The prior 1.1.1 output was `1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`. Version 1.2.0 adds the configuration command; its reproducible SHA-256 is `9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`. +Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of +unrelated content and non-Java resource changes; its reproducible SHA-256 is +`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/plugins/java-usages/openidea-java-usages.jar b/plugins/java-usages/openidea-java-usages.jar index 3f521b3a..bedb5a1a 100644 Binary files a/plugins/java-usages/openidea-java-usages.jar and b/plugins/java-usages/openidea-java-usages.jar differ diff --git a/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java index 39fce3a9..fdf037bb 100644 --- a/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java +++ b/plugins/java-usages/src/ai/openidea/configuration/ConfigurationCommand.java @@ -27,9 +27,23 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { private static final String VALUE = "org.springframework.beans.factory.annotation.Value"; private static final String RESOLVER = "org.springframework.core.env.PropertyResolver"; private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024; - private static final AtomicLong REVISION = new AtomicLong(); + private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong(); private static final Map CACHE = new LinkedHashMap<>(); - static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); } + /** 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 + * (configuration files are parsed by the client; metadata files carry their + * 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); } + static boolean affects(IJavaElementDelta delta) { + int flags = delta.getFlags() & ~IGNORED_FLAGS; + if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT; + if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true; + for (IJavaElementDelta child : delta.getAffectedChildren()) if (affects(child)) return true; + return false; + } private record Entry(String name, String handle, Location location, boolean relaxed) {} private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {} @@ -38,7 +52,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { catch (java.io.IOException e) { return new FileStamp(null, -1); } } private record Index(long revision, String workingCopies, List entries, List roots, - Map files, boolean incomplete, int parsed) {} + Map files, boolean incomplete, int parsed, long generation) {} @Override public Object executeCommand(String command, List args, IProgressMonitor monitor) throws Exception { @@ -108,7 +122,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { } return Map.of("locations", locations, "keys", keys, "roots", index.roots, "incomplete", index.incomplete || locations.size() >= 2000, - "parsedUnits", index.parsed, "revision", index.revision); + "parsedUnits", index.parsed, "revision", index.revision, "generation", index.generation); } private static boolean sameUri(String a, String b) { @@ -156,9 +170,12 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy); for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) copies.put(copy.getPrimary().getHandleIdentifier(), copy); + // Only unsaved contents differ from what the Java model reports for disk, + // so merely opening or closing an editor keeps the snapshot. var digest = java.security.MessageDigest.getInstance("SHA-256"); for (var entry : copies.entrySet()) { check(monitor); + if (!entry.getValue().hasUnsavedChanges()) continue; digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8)); String source = entry.getValue().getSource(); if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)); @@ -209,7 +226,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler { // Return project locations as well; the client excludes nested independent modules. 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); + 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)) diff --git a/plugins/java-usages/tests/configuration.py b/plugins/java-usages/tests/configuration.py index 9a42f4a0..f218019f 100644 --- a/plugins/java-usages/tests/configuration.py +++ b/plugins/java-usages/tests/configuration.py @@ -200,6 +200,33 @@ try: warm = [] for _ in range(20): start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start) + # The snapshot stays warm across editor activity that cannot change it: + # opening/closing another file, a full build, and an edit once it is saved. + generation = query('app.time-out')['generation'] + other = source_dir / 'demo/NotSpring.java' + other_text = other.read_text() + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}}) + assert query('app.time-out')['generation'] == generation, 'opening an editor rebuilt the index' + send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}}) + request('java/buildWorkspace', True) + assert query('app.time-out')['generation'] == generation, 'closing an editor or building rebuilt the index' + saved_text = other_text + '// saved\n' + send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}}) + send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': other.as_uri(), 'version': 2}, 'contentChanges': [{'text': saved_text}]}}) + other.write_text(saved_text) + send({'jsonrpc': '2.0', 'method': 'textDocument/didSave', 'params': {'textDocument': {'uri': other.as_uri()}}}) + after_save = query('app.time-out')['generation'] + assert query('app.time-out')['generation'] == after_save, 'a saved editor keeps rebuilding the index' + send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}}) + after_close = query('app.time-out')['generation'] + assert query('app.time-out')['generation'] == after_close + # A source added on disk still invalidates it. + added = write('demo/Added.java', 'package demo; @org.springframework.boot.context.properties.ConfigurationProperties("added") class Added { int size; }') + send({'jsonrpc': '2.0', 'method': 'workspace/didChangeWatchedFiles', 'params': {'changes': [{'uri': added.as_uri(), 'type': 1}]}}) + deadline = time.monotonic() + 30 + while not query('added.size')['locations']: + assert time.monotonic() < deadline, 'a new source on disk did not reach the index' + time.sleep(0.2) # 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}]}}) @@ -211,7 +238,7 @@ 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} + 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]} (work / 'evidence.json').write_text(json.dumps(evidence, indent=2)) print(json.dumps(evidence, indent=2)) finally: