mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]})
|
||||
|
||||
Reference in New Issue
Block a user