perf(java): reuse usage search results and skip covered folders when widening (#2278)

Find Usages and usage counts searched again on every request: about two
seconds each for a common type such as String.

- The Java usages extension (1.12.0) keeps answers by search target until a
  Java element, the classpath or an unsaved edit changes, and requests for
  the same target share one search.
- Widening checks up to 5000 candidates against the server's analysis scope
  before applying the 300-file limit, so names mentioned in many covered
  files no longer report "too many files".
- Candidate scans skip the source folders the server covers fully
  (openidea.workspace.coveredRoots) instead of reading every file.
- Commands a server registers after initializing are added to its commands.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 14:44:48 +08:00
1 джерело d2603facdf
коміт 1a008fd17e
13 files changed
+439 -28

No files matched your search

+1 -1
Переглянути файл
@@ -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.9.0
Bundle-Version: 1.12.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,
+17 -2
Переглянути файл
@@ -27,6 +27,11 @@ from directory names. References, rename and incoming calls leave those files cl
and only widen with the remaining candidates. A missing or failing command retains
widening. The result is query-local, so imports and classpath changes need no cache invalidation.
`openidea.workspace.coveredRoots()` returns the URIs of source folders whose Java files the search
scope covers all: no exclusion patterns, no inclusion pattern other than Maven's `**/*.java`, and
enclosed by the test-source policy. The client does not read the files under them when it looks
for candidates, which on a large project took most of a cached search's time.
## Search plan and access semantics
For source fields on the verified JDT LS implementation, a single plan merges
@@ -58,7 +63,14 @@ traversals. It indexes only requested exact AST ranges and verified invocation
selector-to-call-end spans; duplicate, recovered, malformed or uncontained ranges
fall back to NodeFinder. Method references do not get invocation aliases. Source
snapshot checks remain per result, and the index is discarded after the query.
There is no cross-request cache or binding-resolution shortcut.
Answers are cached by their search targets (JDT handles, kinds, declarations), the scope and the
search settings, together with a digest of the editors' unsaved contents, until a Java element
delta reports a change to the sources, a compilation unit or a classpath (`UsageCache`). Opening
or closing an editor and decompiled library classes' private working copies do not invalidate
them. A usage count and the usages shown when it is clicked, or the same search run again, need
no second search, and concurrent requests for the same targets share one. There is no
binding-resolution shortcut.
Popup and Find share persisted read/write toolbar switches. Both initially show;
turning the last one off automatically enables the other. Unknown and readWrite
@@ -76,7 +88,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.9.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.12.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.
@@ -118,6 +130,9 @@ longer resolves every Spring Boot property again; its reproducible SHA-256 is
Version 1.9.0 adds the analysis-scope command to avoid redundant semantic queries;
its reproducible SHA-256 is
`ae04182d6a86dfdd6f6befaaeb28907fd9e4c022ceedb445f0b32ea917dca379`.
Version 1.12.0 caches usages answers until the Java model or an unsaved edit changes and adds
`openidea.workspace.coveredRoots`; its reproducible SHA-256 is
`c1bd28d7b956cb64af3e936aac0553413a5dc5fd373a043ddba003009e0ea783`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
Бінарний файл не відображається.
+3
Переглянути файл
@@ -4,6 +4,9 @@
<delegateCommandHandler class="ai.openidea.usages.CoveredFilesCommand">
<command id="openidea.workspace.coveredFiles"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.usages.CoveredRootsCommand">
<command id="openidea.workspace.coveredRoots"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.usages.UsagesCommand">
<command id="openidea.java.usages"/>
</delegateCommandHandler>
+51
Переглянути файл
@@ -0,0 +1,51 @@
package ai.openidea.usages;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.IClasspathEntry;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.search.IJavaSearchScope;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.managers.ProjectsManager;
import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope;
/**
* Directories whose Java files are all in the workspace's semantic source searches: source
* folders without inclusion or exclusion patterns that the search scope encloses. A client
* looking for files beyond that scope need not read the files under them.
*/
public final class CoveredRootsCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
IJavaProject[] projects = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects();
var setting = JavaLanguageServerPlugin.getPreferencesManager().getPreferences().getSearchScope();
var scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects,
IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS);
List<String> covered = new ArrayList<>();
for (IJavaProject project : projects) {
// The default project holds standalone open files, not an indexed source tree.
if (ProjectsManager.getDefaultProject().equals(project.getProject())) continue;
for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
if (monitor.isCanceled()) throw new OperationCanceledException();
if (root.getKind() != IPackageFragmentRoot.K_SOURCE || root.getResource() == null) continue;
IClasspathEntry entry = root.getRawClasspathEntry();
if (entry == null || entry.getExclusionPatterns().length > 0) continue;
// Maven projects include `**/*.java`: every Java file, like no pattern at all.
boolean allJava = true;
for (var pattern : entry.getInclusionPatterns()) allJava &= pattern.toString().equals("**/*.java");
if (!allJava) continue;
if (!scope.encloses(root)) continue;
var location = root.getResource().getLocationURI();
if (location != null) covered.add(location.toString());
}
}
return covered;
}
}
+157
Переглянути файл
@@ -0,0 +1,157 @@
package ai.openidea.usages;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicLong;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.ElementChangedEvent;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaElementDelta;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.managers.ProjectsManager;
/**
* Answers of `openidea.java.usages` by search targets, kept until a Java element changes: the
* sources or unsaved edits of a compilation unit, or a project's classpath. A usage count and
* the usages shown when it is clicked, or the same search run again, then need no new search.
* Concurrent requests for the same targets share one search.
*/
final class UsageCache {
private static final int MAX_ENTRIES = 32, MAX_ROWS = 200_000;
/** Opening or closing an editor's working copy and AST-only reconciles change no usage. */
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
private static final AtomicLong REVISION = new AtomicLong();
private record Entry(long revision, List<Map<String, Object>> rows) {}
private static final Map<String, Entry> DONE = new LinkedHashMap<>(16, 0.75f, true);
private static final Map<String, CompletableFuture<List<Map<String, Object>>>> RUNNING = new HashMap<>();
static {
JavaCore.addElementChangedListener(event -> {
if (affects(event.getDelta())) REVISION.incrementAndGet();
}, ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE);
}
private UsageCache() {}
interface Search {
List<Map<String, Object>> run() throws Exception;
}
static boolean affects(IJavaElementDelta delta) {
// Decompiled library classes open as private working copies; searches never see them.
if (delta.getElement() instanceof ICompilationUnit unit && unit.getOwner() != null) return false;
// Files outside the source folders (opened to widen a search) join the default project
// while open: they are part of the key instead (`openContents`).
IJavaProject project = delta.getElement().getJavaProject();
if (project != null && standalone(project)) return false;
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;
}
static List<Map<String, Object>> get(String targets, IProgressMonitor monitor, Search search) throws Exception {
// Searches read unsaved editor contents, which change before the reconcile that reports them.
String key = targets + '|' + openContents(monitor);
while (true) {
long revision = REVISION.get();
CompletableFuture<List<Map<String, Object>>> running;
boolean owner = false;
synchronized (UsageCache.class) {
Entry done = DONE.get(key);
if (done != null && done.revision == revision) return done.rows;
running = RUNNING.get(key);
if (running == null) {
running = new CompletableFuture<>();
RUNNING.put(key, running);
owner = true;
}
}
if (owner) return search(key, revision, running, search);
// The same targets are searched for another request: its answer, or a new search
// when that one was cancelled, failed or saw a change.
while (true) {
if (monitor.isCanceled()) throw new OperationCanceledException();
try {
List<Map<String, Object>> rows = running.get(50, TimeUnit.MILLISECONDS);
if (REVISION.get() == revision) return rows;
break;
} catch (TimeoutException e) {
continue;
} catch (ExecutionException e) {
break;
}
}
}
}
private static boolean standalone(IJavaProject project) {
return ProjectsManager.getDefaultProject().equals(project.getProject());
}
/** A digest of the editors' unsaved contents and of the open files outside the source
* folders: merely opening or closing a file of the sources keeps it. */
private static String openContents(IProgressMonitor monitor) throws Exception {
Map<String, ICompilationUnit> copies = new TreeMap<>();
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);
var digest = MessageDigest.getInstance("SHA-256");
for (var entry : copies.entrySet()) {
if (monitor.isCanceled()) throw new OperationCanceledException();
ICompilationUnit copy = entry.getValue();
if (!copy.hasUnsavedChanges() && !standalone(copy.getJavaProject())) continue;
digest.update(entry.getKey().getBytes(StandardCharsets.UTF_8));
String source = entry.getValue().getSource();
if (source != null) digest.update(source.getBytes(StandardCharsets.UTF_8));
}
return HexFormat.of().formatHex(digest.digest());
}
private static List<Map<String, Object>> search(String key, long revision,
CompletableFuture<List<Map<String, Object>>> running, Search search) throws Exception {
List<Map<String, Object>> rows;
try {
rows = List.copyOf(search.run());
} catch (Throwable e) {
synchronized (UsageCache.class) { RUNNING.remove(key); }
running.completeExceptionally(e);
throw e;
}
synchronized (UsageCache.class) {
RUNNING.remove(key);
if (REVISION.get() == revision && rows.size() <= MAX_ROWS) remember(key, new Entry(revision, rows));
}
running.complete(rows);
return rows;
}
private static void remember(String key, Entry entry) {
DONE.put(key, entry);
int rows = DONE.values().stream().mapToInt(e -> e.rows.size()).sum();
// Least recently used first; `get` on this access-ordered map would reorder it.
var oldest = DONE.entrySet().iterator();
while ((DONE.size() > MAX_ENTRIES || rows > MAX_ROWS) && oldest.hasNext()) {
var evicted = oldest.next();
if (evicted.getKey().equals(key)) continue;
rows -= evicted.getValue().rows.size();
oldest.remove();
}
}
}
+25 -4
Переглянути файл
@@ -92,13 +92,34 @@ public final class UsagesCommand implements IDelegateCommandHandler {
}
}
}
for (var target : plan.targets.values()) {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
add(search, target.element, target.kind(), target.declarations, results, monitor);
}
UsageCache.Search run = () -> respond(search, plan, results, monitor);
// Unverified builds answer partly through the native handler, before the plan.
return replicated && !plan.targets.isEmpty()
? UsageCache.get(cacheKey(plan, sources, preferences), monitor, run) : run.run();
} finally {
if (targetRoot != null) JDTUtils.discardClassFileWorkingCopy(targetRoot);
}
}
/** What a search depends on besides the Java model: its targets, scope and search settings. */
private static String cacheKey(SearchPlan plan, boolean sources,
org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager preferences) {
var settings = preferences.getPreferences();
StringBuilder key = new StringBuilder().append(sources).append('|').append(settings.getSearchScope())
.append('|').append(preferences.isClientSupportsClassFileContent() && settings.isIncludeDecompiledSources());
for (var target : plan.targets.values()) {
key.append('|').append(target.element.getHandleIdentifier()).append('#').append(target.kind())
.append('#').append(target.declarations);
}
return key.toString();
}
private static List<Map<String, Object>> respond(Search search, SearchPlan plan, Map<Location, String> results,
IProgressMonitor monitor) throws Exception {
for (var target : plan.targets.values()) {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
add(search, target.element, target.kind(), target.declarations, results, monitor);
}
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
List<Map<String, Object>> response = new ArrayList<>();
UsageAccess access = new UsageAccess(monitor, results);
+25
Переглянути файл
@@ -136,6 +136,31 @@ try:
line, text = next((i, t) for i, t in enumerate(source.splitlines()) if 'println' in t)
evidence['jdkSources'] = assert_sources(line, text.index('println'))
assert evidence['jdkSources'], 'no JDK method usages in the sources'
# Answers are reused until the sources change: an unsaved edit is seen at once, before its reconcile.
line, text = next((i, t) for i, t in enumerate(source.splitlines()) if 'private String name;' in t)
usages = lambda: request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,text.index('name'),False]})
getters = lambda rows: [r['kind'] for r in rows].count('getter')
first = usages()
assert usages() == first
edited = source.replace(' void calls(Bean b)', ' void more(Bean b) { b.getName(); }\n void calls(Bean b)')
send({'jsonrpc':'2.0','method':'textDocument/didChange','params':{'textDocument':{'uri':file.as_uri(),'version':2},'contentChanges':[{'text':edited}]}})
changed = usages()
assert getters(changed) == getters(first) + 1, (first, changed)
send({'jsonrpc':'2.0','method':'textDocument/didChange','params':{'textDocument':{'uri':file.as_uri(),'version':3},'contentChanges':[{'text':source}]}})
assert usages() == first
# The source folder holds only covered files; the folder beside it does not.
roots = request('workspace/executeCommand', {'command':'openidea.workspace.coveredRoots','arguments':[]})
assert [pathlib.Path(r.replace('file:', '', 1)).resolve() for r in roots] == [(project / 'src').resolve()], roots
evidence['coveredRoots'] = roots
# A file outside the source folders, opened and closed to widen a search, leaves the answer as it was.
extra = project / 'other' / 'Extra.java'
extra.parent.mkdir()
extra.write_text('class Extra { void use(Bean b) { b.getName(); } }\n')
send({'jsonrpc':'2.0','method':'textDocument/didOpen','params':{'textDocument':{'uri':extra.as_uri(),'languageId':'java','version':1,'text':extra.read_text()}}})
widened = usages()
send({'jsonrpc':'2.0','method':'textDocument/didClose','params':{'textDocument':{'uri':extra.as_uri()}}})
assert usages() == first
evidence['cached'] = {'first':first, 'edited':changed, 'widened':widened}
# Respect the existing global preference: the command must not switch it on.
send({'jsonrpc':'2.0','method':'workspace/didChangeConfiguration','params':{'settings':{'java':{'references':{'includeAccessors':False}}}}})
disabled = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),3,19,False]})