feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837)

This commit is contained in:
l0ng-ai
2026-11-06 21:46:29 +08:00
committed by GitHub
parent ef95cd1716
commit a6d17543fd
26 changed files with 963 additions and 96 deletions
+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.6.0
Bundle-Version: 1.7.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,
+21 -5
View File
@@ -12,6 +12,14 @@ The Rust bridge logs the failure once per project session. Cancelled requests an
changed source documents remain cancelled and do not start a fallback search.
No global `includeAccessors` preference is changed.
An optional fifth argument `"sources"` restricts the search to the workspace's
source folders (no dependency jars, no JDK). A library method can have thousands of
usages in those, which take seconds to search. Find Usages searches the sources alone
(Project Files) unless asked for Project and Libraries; then it shows the sources' usages
first and runs the full search after it. Rows in library classes (`jdt:` URIs) carry their
line as `text`, from the contents the client would request for the class, so the client
need not fetch thousands of class files to show them.
## Search plan and access semantics
For source fields on the verified JDT LS implementation, a single plan merges
@@ -20,10 +28,14 @@ Targets are deduplicated by JDT element identity before search, retaining native
search scope and ranges. Builder results remain unknown. The optimized native
plan is guarded by SHA-256 of ReferencesHandler.class:
`31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064`
(JDT LS 1.63.0.202610191322). Different implementations, non-field selections and
library/decompiled selections retain native discovery plus deduplicated category
searches. This conservative fallback preserves coverage when the server changes;
it does not promise the same speedup on unverified versions.
(JDT LS 1.63.0.202610191322). On that version, non-field and library/decompiled
selections resolve their target as `ReferencesHandler.findReferences` does, and every
search replicates `ReferencesHandler.search` with one difference: a class file whose
decompiled source fails to parse loses only its own matches (logged), where the server
throws and fails the whole search after searching most dependencies. Different
implementations retain native discovery plus deduplicated category searches. This
conservative fallback preserves coverage when the server changes; it does not promise
the same speedup or fault isolation on unverified versions.
SearchEngine resolves exact targets. A query-local syntax AST per referenced source
file supplies expression context without another reference search: simple field
@@ -57,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.6.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.7.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.
@@ -87,6 +99,10 @@ Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configurat
low-priority background job after import, and builds them under cancellable per-project locks
instead of one global lock; its reproducible SHA-256 is
`33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`.
Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches each class
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`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
Binary file not shown.
@@ -4,13 +4,25 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.core.search.IJavaSearchConstants;
import org.eclipse.jdt.core.search.IJavaSearchScope;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.core.search.SearchMatch;
import org.eclipse.jdt.core.search.SearchParticipant;
import org.eclipse.jdt.core.search.SearchPattern;
import org.eclipse.jdt.core.search.SearchRequestor;
import org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.SearchUtils;
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler;
import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope;
import org.eclipse.lsp4j.*;
/** Classifies search results using JDT method identities, never reference text. */
@@ -21,8 +33,14 @@ public final class UsagesCommand implements IDelegateCommandHandler {
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
boolean declarations = Boolean.TRUE.equals(arguments.get(3));
// "sources": the workspace's source folders only, without dependency jars or the JDK. A
// library method's usages there are found long before the full search ends.
boolean sources = arguments.size() > 4 && "sources".equals(arguments.get(4));
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
var handler = new ReferencesHandler(preferences);
// The verified server's search is replicated (see `search`); others keep their own.
boolean replicated = sources || NATIVE_PLAN_SUPPORTED;
Search search = replicated ? (element, locations, m, d) -> search(element, locations, m, d, sources) : handler::search;
var params = new ReferenceParams(new TextDocumentIdentifier(uri), new Position(line, column), new ReferenceContext(declarations));
Map<Location, String> results = new LinkedHashMap<>();
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
@@ -31,40 +49,95 @@ public final class UsagesCommand implements IDelegateCommandHandler {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
boolean accessors = preferences.getPreferences().isIncludeAccessors();
SearchPlan plan = new SearchPlan();
// Only replace native target discovery for the exact implementation verified by
// integration tests. New server implementations retain their full native behavior.
if (selected instanceof IField field && root instanceof ICompilationUnit &&
root.getResource() != null && NATIVE_PLAN_SUPPORTED) {
plan.add(field, "field", declarations);
if (accessors) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
addNativeBuilderTargets(handler, field, plan);
addConfiguredAccessors(field, plan);
}
} else {
// Includes non-fields, library/decompiled roots, and unverified JDT LS builds.
for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown");
if (selected instanceof IField field) {
ITypeRoot targetRoot = null;
try {
// Only replace native target discovery for the exact implementation verified by
// integration tests. New server implementations retain their full native behavior.
if (selected instanceof IField field && root instanceof ICompilationUnit &&
root.getResource() != null && NATIVE_PLAN_SUPPORTED) {
plan.add(field, "field", declarations);
if (accessors) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
addNativeBuilderTargets(handler, field, plan);
addConfiguredAccessors(field, plan);
}
} else if (replicated) {
// Non-fields and library/decompiled roots: the target ReferencesHandler.findReferences
// resolves (a class file without source through its decompiled working copy), widened
// as it widens a field.
boolean decompiled = preferences.isClientSupportsClassFileContent() && preferences.getPreferences().isIncludeDecompiledSources();
targetRoot = JDTUtils.resolveTypeRoot(uri, decompiled, monitor);
IJavaElement target = targetRoot == null ? null :
JDTUtils.findElementAtSelection(targetRoot, line, column, preferences, monitor);
if (target == null && targetRoot != null) {
target = targetRoot.getElementAt(JsonRpcHelpers.toOffset(targetRoot.getBuffer(), line, column));
}
plan.add(target, target instanceof IField ? "field" : "unknown", declarations);
if (accessors && target instanceof IField field) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
if (NATIVE_PLAN_SUPPORTED) addNativeBuilderTargets(handler, field, plan);
addConfiguredAccessors(field, plan);
}
} else {
// Unverified JDT LS builds.
for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown");
if (selected instanceof IField field) {
plan.add(field, "field", declarations);
if (accessors) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
addConfiguredAccessors(field, plan);
}
}
}
}
for (var target : plan.targets.values()) {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
add(handler, target.element, target.kind(), target.declarations, results, monitor);
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);
}
} finally {
if (targetRoot != null) JDTUtils.discardClassFileWorkingCopy(targetRoot);
}
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
List<Map<String, Object>> response = new ArrayList<>();
UsageAccess access = new UsageAccess(monitor, results);
results.forEach((location, kind) -> response.add(Map.of("location", location, "kind", kind, "access", access.classify(location, kind))));
Map<String, String[]> libraryLines = new java.util.HashMap<>();
for (var entry : results.entrySet()) {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
Location location = entry.getKey();
Map<String, Object> row = new LinkedHashMap<>();
row.put("location", location);
row.put("kind", entry.getValue());
row.put("access", access.classify(location, entry.getValue()));
String text = libraryLine(location, libraryLines, monitor);
if (text != null) row.put("text", text);
response.add(row);
}
return response;
}
/**
* The line of a usage in a library class (`jdt:` URI), from the contents the client would
* request for it: so the client need not fetch thousands of class files to show the lines.
*/
private static String libraryLine(Location location, Map<String, String[]> cache, IProgressMonitor monitor) {
String uri = location.getUri();
if (uri == null || !uri.startsWith("jdt:")) return null;
if (!cache.containsKey(uri)) {
String contents = null;
try {
contents = JavaLanguageServerPlugin.getContentProviderManager().getContent(JDTUtils.toURI(uri), monitor);
} catch (RuntimeException e) {
if (e instanceof org.eclipse.core.runtime.OperationCanceledException) throw e;
}
cache.put(uri, contents == null ? null : contents.split("\\r\\n?|\\n", -1));
}
String[] lines = cache.get(uri);
int line = location.getRange().getStart().getLine();
return lines != null && line >= 0 && line < lines.length ? lines[line] : null;
}
/** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities.
* Configuration-file-only naming is deliberately not guessed.
*/
@@ -191,11 +264,84 @@ public final class UsagesCommand implements IDelegateCommandHandler {
(resolved[0][0].isEmpty() ? resolved[0][1] : resolved[0][0] + "." + resolved[0][1]).equals(owner.getFullyQualifiedName());
}
private static void add(ReferencesHandler handler, IJavaElement element, String kind, boolean declarations,
private interface Search {
void run(IJavaElement element, List<Location> locations, IProgressMonitor monitor, boolean declarations) throws Exception;
}
/**
* ReferencesHandler.search of the verified server, except that a class file whose decompiled
* source fails to parse loses only its own matches: there the server throws and fails the
* whole search, after searching most of the dependencies. `sources`: the workspace's source
* folders only, without dependency jars and the JDK.
*/
private static void search(IJavaElement element, List<Location> locations, IProgressMonitor monitor,
boolean declarations, boolean sources) throws CoreException {
SearchPattern pattern = SearchPattern.createPattern(element, IJavaSearchConstants.REFERENCES);
if (pattern == null) return;
if (declarations) {
SearchPattern declaration = SearchPattern.createPattern(element, IJavaSearchConstants.DECLARATIONS);
if (declaration != null) pattern = SearchPattern.createOrPattern(pattern, declaration);
}
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
SearchScope setting = preferences.getPreferences().getSearchScope();
int mask = IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS;
if (!sources && setting != SearchScope.projectOnly) {
mask |= IJavaSearchScope.APPLICATION_LIBRARIES;
if (insideJre(element)) mask |= IJavaSearchScope.SYSTEM_LIBRARIES;
}
IJavaProject[] projects = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects();
IJavaSearchScope scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects, mask);
boolean classFiles = preferences.isClientSupportsClassFileContent();
boolean decompiled = preferences.getPreferences().isIncludeDecompiledSources();
new SearchEngine().search(pattern, new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() }, scope,
new SearchRequestor() {
@Override
public void acceptSearchMatch(SearchMatch match) throws CoreException {
if (match.getAccuracy() == SearchMatch.A_INACCURATE || !(match.getElement() instanceof IJavaElement found)) return;
if (found.getAncestor(IJavaElement.COMPILATION_UNIT) instanceof ICompilationUnit unit) {
locations.add(JDTUtils.toLocation(unit, match.getOffset(), match.getLength()));
return;
}
if (!classFiles || !(found.getAncestor(IJavaElement.CLASS_FILE) instanceof IClassFile classFile)) return;
if (classFile.getSourceRange() != null) {
locations.add(JDTUtils.toLocation(classFile, match.getOffset(), match.getLength()));
return;
}
if (!decompiled) return;
try {
if (found instanceof IMember member) {
Location location = SearchUtils.searchOtherSources(member);
if (location != null) locations.add(location);
}
locations.addAll(JDTUtils.searchDecompiledSources(found, classFile, false, false, monitor));
} catch (org.eclipse.core.runtime.OperationCanceledException e) {
throw e;
} catch (RuntimeException | CoreException e) {
JavaLanguageServerPlugin.logInfo("Usages in decompiled " + classFile.getElementName() + " skipped: " + e);
}
}
}, monitor);
}
/** ReferencesHandler.isInsideJRE: a JDK element is searched in the JDK too. */
private static boolean insideJre(IJavaElement element) {
if (!(element.getAncestor(IJavaElement.PACKAGE_FRAGMENT_ROOT) instanceof IPackageFragmentRoot root)) return true;
try {
IClasspathEntry entry = root.getRawClasspathEntry();
if (entry.getEntryKind() != IClasspathEntry.CPE_CONTAINER) return false;
IClasspathContainer container = JavaCore.getClasspathContainer(entry.getPath(), root.getJavaProject());
return container != null && container.getKind() == IClasspathContainer.K_DEFAULT_SYSTEM;
} catch (JavaModelException e) {
JavaLanguageServerPlugin.logException(e);
return true;
}
}
private static void add(Search search, IJavaElement element, String kind, boolean declarations,
Map<Location, String> results, IProgressMonitor monitor) throws Exception {
if (element == null) return;
List<Location> locations = new ArrayList<>();
handler.search(element, locations, monitor, declarations);
search.run(element, locations, monitor, declarations);
for (var location : locations) {
if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; }
String previous = results.get(location);
+13
View File
@@ -106,6 +106,14 @@ def request(method, params):
if 'error' in msg: raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(method)
def assert_sources(line, col, full=None):
"""The "sources" scope finds what the full search finds in the workspace's sources."""
usages = lambda *scope: request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,col,False,*scope]})
full = usages() if full is None else full
sources = usages('sources')
key = lambda rows: sorted(json.dumps(r, sort_keys=True) for r in rows)
assert key(sources) == key(r for r in full if r['location']['uri'].startswith('file:')), (line, col, sources, full)
return sources
try:
init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, 'import':{'gradle':{'enabled':False}, 'maven':{'enabled':False}}}}}})
send({'jsonrpc':'2.0','method':'initialized','params':{}})
@@ -123,6 +131,11 @@ try:
assert kinds.count('getter') == expected[0], (field, kinds)
assert kinds.count('setter') == expected[1], (field, kinds)
assert kinds.count('field') == expected[2], (field, kinds)
assert_sources(line, col, after)
# A JDK method: in the sources, the same as the full search's sources part.
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'
# 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]})