feat(java): add read/write filters for Java field usages (#569)

Find Usages mixed reads and writes of a Java field, including Lombok getter and setter calls, because jdtls has no read/write classification in its references. The usages popup and Find window now have Read and Write toggles. Read-write accesses stay visible in either mode and unclassified results are kept.

Classification comes from a jdtls extension in `plugins/java-usages`, embedded as a jar and installed beside the other bundles. The checked-in jar was rebuilt from source with ECJ and matches byte for byte. Any extension failure falls back to standard references, so a jdtls upgrade can lose the filter but not Find Usages; rebuilding the jar on an upgrade is manual and described in the plugin README. Older installed jars are removed. Other languages keep their plain result count.

Co-authored-by: NingYuanLin <[email protected]>
Цей коміт міститься в:
Ning YuanLin authored and GitHub committed 2026-11-01 09:09:15 +08:00
1 джерело c3d569d3e3
коміт 6456f0e404
36 files changed
+1933 -38

No files matched your search

+146
Переглянути файл
@@ -0,0 +1,146 @@
package ai.openidea.usages;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.*;
import org.eclipse.jdt.core.dom.*;
import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.lsp4j.Location;
/** Structural access context for already semantically bound search results.
* One syntax parse per source file per query; no second reference search.
*/
final class UsageAccess {
private record Parsed(ICompilationUnit unit, String source, CompilationUnit tree, Map<Long, ASTNode> exactNodes) {}
private final Map<String, Parsed> files = new HashMap<>();
private final IProgressMonitor monitor;
private final Map<String, List<Location>> requests = new HashMap<>();
UsageAccess(IProgressMonitor monitor, Map<Location, String> results) {
this.monitor = monitor;
results.forEach((location, kind) -> {
if (!kind.equals("unknown")) requests.computeIfAbsent(location.getUri(), k -> new ArrayList<>()).add(location);
});
}
private static long rangeKey(int start, int length) { return ((long) start << 32) | (length & 0xffffffffL); }
private Map<Long, ASTNode> index(CompilationUnit tree, String uri) {
List<Location> locations = requests.get(uri);
// A few lookups do not justify traversing an entire file. Index exact AST
// and selector-to-call-end ranges; all other ranges retain NodeFinder.
if (locations == null || locations.size() < 16) return Map.of();
var wanted = new HashSet<Long>();
for (Location location : locations) {
var range = location.getRange();
int start = tree.getPosition(range.getStart().getLine() + 1, range.getStart().getCharacter());
int end = tree.getPosition(range.getEnd().getLine() + 1, range.getEnd().getCharacter());
if (start >= 0 && end > start) wanted.add(rangeKey(start, end - start));
}
Map<Long, ASTNode> nodes = new HashMap<>();
tree.accept(new ASTVisitor(true) {
int visited;
@Override public void preVisit(ASTNode node) {
if ((visited++ & 127) == 0 && monitor.isCanceled()) throw new OperationCanceledException();
int start = node.getStartPosition();
long end = (long) start + node.getLength();
add(rangeKey(start, node.getLength()), node);
// JDT method matches may start at the selector (after receiver)
// and end after the arguments. That span is covered by this call,
// but not by any of its children. It is not a method reference.
SimpleName name = node instanceof MethodInvocation call ? call.getName() :
node instanceof SuperMethodInvocation call ? call.getName() : null;
if (name != null && end <= Integer.MAX_VALUE && name.getLength() > 0 &&
(name.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) == 0 &&
name.getStartPosition() >= start && end > (long) name.getStartPosition() + name.getLength())
add(rangeKey(name.getStartPosition(), (int) (end - name.getStartPosition())), node);
}
private void add(long key, ASTNode node) {
if (!wanted.contains(key)) return;
if (nodes.containsKey(key)) {
if (nodes.get(key) != node) nodes.put(key, null);
return;
}
boolean contained = true;
for (ASTNode parent = node; parent != null; parent = parent.getParent()) {
if ((parent.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) != 0 ||
parent.getStartPosition() > node.getStartPosition() ||
(long) parent.getStartPosition() + parent.getLength() < (long) node.getStartPosition() + node.getLength()) {
contained = false;
break;
}
}
nodes.put(key, contained ? node : null);
}
});
return nodes;
}
String classify(Location location, String kind) {
if (kind.equals("unknown")) return "unknown";
if (monitor.isCanceled()) throw new OperationCanceledException();
try {
String uri = location.getUri();
if (!files.containsKey(uri)) {
Parsed parsed = null;
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
if (root instanceof ICompilationUnit unit && unit.getResource() != null) {
String source = unit.getBuffer().getContents();
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setSource(source.toCharArray());
parser.setCompilerOptions(unit.getJavaProject().getOptions(true));
// SearchEngine has already resolved the target identity. Here only
// expression structure is needed; do not resolve the project again.
CompilationUnit tree = (CompilationUnit) parser.createAST(monitor);
parsed = new Parsed(unit, source, tree, index(tree, uri));
}
files.put(uri, parsed);
}
Parsed file = files.get(uri);
if (file == null || !file.source.equals(file.unit.getBuffer().getContents())) return "unknown";
var range = location.getRange();
int start = file.tree.getPosition(range.getStart().getLine() + 1, range.getStart().getCharacter());
int end = file.tree.getPosition(range.getEnd().getLine() + 1, range.getEnd().getCharacter());
if (start < 0 || end < start) return "unknown";
ASTNode node = file.exactNodes.get(rangeKey(start, end - start));
if (node == null) node = NodeFinder.perform(file.tree, start, end - start);
if (node == null || (node.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) != 0) return "unknown";
if (kind.equals("getter") || kind.equals("setter")) {
for (ASTNode p = node; p != null; p = p.getParent()) {
if (p instanceof MethodReference) return "unknown";
if (p instanceof MethodInvocation || p instanceof SuperMethodInvocation)
return kind.equals("getter") ? "read" : "write";
if (p instanceof Statement || p instanceof BodyDeclaration) break;
}
return "unknown";
}
if (!(node instanceof SimpleName name) || name.isDeclaration()) return "unknown";
ASTNode expression = node;
while (true) {
ASTNode parent = expression.getParent();
if (parent instanceof QualifiedName q && q.getName() == expression ||
parent instanceof FieldAccess f && f.getName() == expression ||
parent instanceof SuperFieldAccess f && f.getName() == expression ||
parent instanceof ParenthesizedExpression p && p.getExpression() == expression) {
expression = parent;
} else break;
}
ASTNode parent = expression.getParent();
if (parent instanceof Assignment a && a.getLeftHandSide() == expression)
return a.getOperator() == Assignment.Operator.ASSIGN ? "write" : "readWrite";
if (parent instanceof PostfixExpression) return "readWrite";
if (parent instanceof PrefixExpression p && (p.getOperator() == PrefixExpression.Operator.INCREMENT ||
p.getOperator() == PrefixExpression.Operator.DECREMENT)) return "readWrite";
return "read";
} catch (OperationCanceledException e) {
throw e;
} catch (Exception e) {
// A missing/changed/unparseable document must remain visible, never guessed.
return "unknown";
}
}
}
+205
Переглянути файл
@@ -0,0 +1,205 @@
package ai.openidea.usages;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.*;
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.handlers.ReferencesHandler;
import org.eclipse.lsp4j.*;
/** Classifies search results using JDT method identities, never reference text. */
public final class UsagesCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
String uri = (String) arguments.get(0);
int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue();
boolean declarations = Boolean.TRUE.equals(arguments.get(3));
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
var handler = new ReferencesHandler(preferences);
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);
IJavaElement selected = root == null ? null :
JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
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) {
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);
}
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))));
return response;
}
/** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities.
* Configuration-file-only naming is deliberately not guessed.
*/
private static void addConfiguredAccessors(IField field, SearchPlan plan) throws Exception {
Boolean fluent = null;
boolean configured = false;
String[] prefixes = null;
// Field annotations override enclosing type annotations, as Lombok does.
List<IAnnotatable> owners = new ArrayList<>();
owners.add(field);
for (IType type = field.getDeclaringType(); type != null; type = type.getDeclaringType()) owners.add(type);
for (IAnnotatable owner : owners) {
for (IAnnotation annotation : owner.getAnnotations()) {
String name = annotation.getElementName();
if (!name.equals("Accessors") && !name.equals("lombok.experimental.Accessors")) continue;
String[][] resolved = field.getDeclaringType().resolveType(name);
if (!name.equals("lombok.experimental.Accessors") && (resolved == null || resolved.length != 1 ||
!resolved[0][0].equals("lombok.experimental") || !resolved[0][1].equals("Accessors"))) continue;
configured = true;
for (IMemberValuePair pair : annotation.getMemberValuePairs()) {
if (pair.getMemberName().equals("fluent") && fluent == null && pair.getValue() instanceof Boolean value) fluent = value;
if (pair.getMemberName().equals("prefix") && prefixes == null) {
Object value = pair.getValue();
if (value instanceof String prefix) prefixes = new String[] { prefix };
else if (value instanceof Object[] values) {
prefixes = new String[values.length];
for (int i = 0; i < values.length; i++) prefixes[i] = (String) values[i];
}
}
}
}
}
if (!configured) return;
String name = field.getElementName();
if (prefixes != null && prefixes.length > 0) {
String stripped = null;
for (String prefix : prefixes) {
if (prefix.isEmpty()) { stripped = name; break; }
if (!name.startsWith(prefix) || name.length() == prefix.length()) continue;
char next = name.charAt(prefix.length());
if (Character.isLetter(prefix.charAt(prefix.length() - 1)) && Character.isLowerCase(next)) continue;
stripped = Character.toLowerCase(next) + name.substring(prefix.length() + 1);
break;
}
if (stripped == null) return;
name = stripped;
}
String signature = field.getTypeSignature();
String getterName = name;
String setterName = name;
if (!Boolean.TRUE.equals(fluent)) {
boolean bool = signature.equals(Signature.SIG_BOOLEAN);
String base = name;
if (bool && base.startsWith("is") && base.length() > 2 && Character.isUpperCase(base.charAt(2))) base = base.substring(2);
String capital = Character.toUpperCase(base.charAt(0)) + base.substring(1);
getterName = (bool ? "is" : "get") + capital;
setterName = "set" + capital;
}
for (IMethod method : field.getDeclaringType().getMethods()) {
if (Flags.isStatic(method.getFlags()) != Flags.isStatic(field.getFlags())) continue;
if (method.getElementName().equals(getterName) && method.getNumberOfParameters() == 0 && method.getReturnType().equals(signature)) {
plan.add(method, "getter", false);
} else if (method.getElementName().equals(setterName) && method.getNumberOfParameters() == 1 && method.getParameterTypes()[0].equals(signature) && setterReturnType(method)) {
plan.add(method, "setter", false);
}
}
}
private static final boolean NATIVE_PLAN_SUPPORTED = nativePlanSupported();
private static boolean nativePlanSupported() {
// JDT LS 1.63.0.202610191322 ReferencesHandler.class. Fingerprinting guards
// against silently dropping new native search targets in future versions.
try (var stream = ReferencesHandler.class.getResourceAsStream("ReferencesHandler.class")) {
if (stream == null) return false;
String digest = java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(stream.readAllBytes()));
return digest.equals("31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064");
} catch (Exception e) {
return false;
}
}
private static void addNativeBuilderTargets(ReferencesHandler handler, IField field, SearchPlan plan) throws Exception {
// Reuse the verified server's own builder naming (including custom names).
var resolver = ReferencesHandler.class.getDeclaredMethod("getBuilderName", IField.class);
resolver.setAccessible(true);
String name = (String) resolver.invoke(handler, field);
IType builder = field.getJavaProject().findType(name);
if (builder == null) return;
for (IMethod method : builder.getMethods()) {
String[] parameters = method.getParameterTypes();
if (parameters.length == 1 && field.getElementName().equals(method.getElementName()) &&
field.getTypeSignature().equals(parameters[0])) plan.add(method, "unknown", false);
}
}
private static final class SearchTarget {
final IJavaElement element;
final java.util.Set<String> kinds = new java.util.HashSet<>();
boolean declarations;
SearchTarget(IJavaElement element) { this.element = element; }
String kind() { return kinds.size() == 1 ? kinds.iterator().next() : "unknown"; }
}
private static final class SearchPlan {
final Map<IJavaElement, SearchTarget> targets = new LinkedHashMap<>();
void add(IJavaElement element, String kind, boolean declarations) {
if (element == null) return;
SearchTarget target = targets.computeIfAbsent(element, SearchTarget::new);
if (!kind.equals("unknown")) target.kinds.add(kind);
target.declarations |= declarations;
}
}
private static boolean setterReturnType(IMethod method) throws JavaModelException {
String result = method.getReturnType();
if (result.equals(Signature.SIG_VOID)) return true;
String name = Signature.toString(Signature.getTypeErasure(result));
IType owner = method.getDeclaringType();
if (name.equals(owner.getFullyQualifiedName())) return true;
String[][] resolved = owner.resolveType(name);
return resolved != null && resolved.length == 1 &&
(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,
Map<Location, String> results, IProgressMonitor monitor) throws Exception {
if (element == null) return;
List<Location> locations = new ArrayList<>();
handler.search(element, locations, monitor, declarations);
for (var location : locations) {
if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; }
String previous = results.get(location);
results.put(location, previous == null || previous.equals("unknown") || previous.equals(kind) ? kind : "unknown");
}
}
}