дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
d2603facdf
коміт
1a008fd17e
13 files changed
+439
-28
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Бінарний файл не відображається.
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]})
|
||||
|
||||
Reference in new issue
Заблокувати користувача