дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
c3d569d3e3
коміт
6456f0e404
36 files changed
+1933
-38
No files matched your search
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача