mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
chore: move the JDT LS components out of plugins/ (#1292)
java-usages, kotlin-bridge and the patched Lombok agent are JDT LS bundles and a Java agent embedded into the app, not OpenIDEA plugins. Move them to java/ so plugins/ holds only plugins, and update the paths that embed or mention them. Also fix the stale build path in the hello fixture and the contributor guide's layout (there is no templates/ directory; starter templates live in the openidea-plugin CLI).
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
package ai.openidea.configuration;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.zip.ZipFile;
|
||||
import com.google.gson.*;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
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.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.Location;
|
||||
|
||||
/** On-demand configuration index, scoped by JDT's resolved source/class path.
|
||||
* Cached records contain strings and locations, never ASTs or bindings. Java model
|
||||
* changes invalidate snapshots, including unsaved working copies and classpath edits.
|
||||
*/
|
||||
public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
private static final String PROPERTIES = "org.springframework.boot.context.properties.ConfigurationProperties";
|
||||
private static final String VALUE = "org.springframework.beans.factory.annotation.Value";
|
||||
private static final String RESOLVER = "org.springframework.core.env.PropertyResolver";
|
||||
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
|
||||
private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong();
|
||||
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
|
||||
/** Unsaved edits are covered by the working-copy digest, so reconciles never
|
||||
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
|
||||
* non-Java resources, reported as F_CONTENT on a project, root or package
|
||||
* (configuration files are parsed by the client; metadata files carry their
|
||||
* own stamps), do not change the index either. */
|
||||
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
|
||||
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
|
||||
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
|
||||
static boolean affects(IJavaElementDelta delta) {
|
||||
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;
|
||||
}
|
||||
|
||||
private record Entry(String name, String handle, Location location, boolean relaxed) {}
|
||||
private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {}
|
||||
private static FileStamp stamp(Path path) {
|
||||
try { return new FileStamp(Files.getLastModifiedTime(path), Files.size(path)); }
|
||||
catch (java.io.IOException e) { return new FileStamp(null, -1); }
|
||||
}
|
||||
private record Index(long revision, String workingCopies, List<Entry> entries, List<String> roots,
|
||||
Map<Path, FileStamp> files, boolean incomplete, int parsed, long generation) {}
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
|
||||
String uri = (String) args.get(0);
|
||||
String key = args.get(1) instanceof String s ? s : null;
|
||||
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
|
||||
boolean references = args.size() > 4 && Boolean.TRUE.equals(args.get(4));
|
||||
boolean declarations = args.size() > 5 && Boolean.TRUE.equals(args.get(5));
|
||||
ITypeRoot selectedRoot = JDTUtils.resolveTypeRoot(uri);
|
||||
IJavaProject project = selectedRoot == null ? project(uri) : selectedRoot.getJavaProject();
|
||||
if (project == null) return Map.of("locations", List.of(), "keys", List.of(), "roots", List.of(), "incomplete", true);
|
||||
Index index = index(project, monitor);
|
||||
Set<Map<String, Object>> keys = new LinkedHashSet<>();
|
||||
Set<Location> locations = new LinkedHashSet<>();
|
||||
String selected = null;
|
||||
if (key == null && selectedRoot != null && references) {
|
||||
var element = JDTUtils.findElementAtSelection(selectedRoot, line, column,
|
||||
JavaLanguageServerPlugin.getPreferencesManager(), monitor);
|
||||
if (element != null) selected = element.getHandleIdentifier();
|
||||
}
|
||||
for (Entry entry : index.entries) {
|
||||
check(monitor);
|
||||
Location location = entry.location;
|
||||
if (key != null) {
|
||||
if (matches(entry.name, key, entry.relaxed)) {
|
||||
if (!entry.handle.isEmpty() && references) {
|
||||
var element = JavaCore.create(entry.handle);
|
||||
if (element != null && element.exists()) {
|
||||
List<Location> usages = new ArrayList<>();
|
||||
new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager())
|
||||
.search(element, usages, monitor, declarations);
|
||||
locations.addAll(usages);
|
||||
}
|
||||
} else {
|
||||
if (location == null && !entry.handle.isEmpty()) location = location(entry.handle);
|
||||
if (location != null) locations.add(location);
|
||||
}
|
||||
keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed));
|
||||
}
|
||||
} else if ((!entry.handle.isEmpty() && entry.handle.equals(selected)) ||
|
||||
(location != null && sameUri(location.getUri(), uri) && contains(location, line, column))) {
|
||||
keys.add(Map.of("name", entry.name, "relaxed", entry.relaxed));
|
||||
}
|
||||
if (locations.size() >= 2000) break;
|
||||
}
|
||||
// Reverse Find Usages joins property consumers with the binding member's
|
||||
// ordinary Java usages. Definition on a string only returns config keys.
|
||||
if (key == null && references && !keys.isEmpty()) {
|
||||
Set<String> searched = new HashSet<>();
|
||||
for (Entry entry : index.entries) {
|
||||
check(monitor);
|
||||
boolean related = keys.stream().anyMatch(pattern ->
|
||||
matches(entry.name, (String) pattern.get("name"), entry.relaxed) ||
|
||||
matches((String) pattern.get("name"), entry.name, Boolean.TRUE.equals(pattern.get("relaxed"))));
|
||||
if (!related) continue;
|
||||
if (entry.location != null) locations.add(entry.location);
|
||||
else if (!entry.handle.isEmpty() && searched.add(entry.handle)) {
|
||||
IJavaElement element = JavaCore.create(entry.handle);
|
||||
if (element != null && element.exists()) {
|
||||
List<Location> usages = new ArrayList<>();
|
||||
new ReferencesHandler(JavaLanguageServerPlugin.getPreferencesManager()).search(element, usages, monitor, declarations);
|
||||
locations.addAll(usages);
|
||||
}
|
||||
}
|
||||
if (locations.size() >= 2000) break;
|
||||
}
|
||||
}
|
||||
return Map.of("locations", locations, "keys", keys, "roots", index.roots,
|
||||
"incomplete", index.incomplete || locations.size() >= 2000,
|
||||
"parsedUnits", index.parsed, "revision", index.revision, "generation", index.generation);
|
||||
}
|
||||
|
||||
private static boolean sameUri(String a, String b) {
|
||||
URI left = URI.create(a), right = URI.create(b);
|
||||
if (left.equals(right)) return true;
|
||||
if (!"file".equals(left.getScheme()) || !"file".equals(right.getScheme())) return false;
|
||||
try { return Path.of(left).toFile().getCanonicalFile().equals(Path.of(right).toFile().getCanonicalFile()); }
|
||||
catch (java.io.IOException e) { return false; }
|
||||
}
|
||||
private static boolean contains(Location l, int line, int col) {
|
||||
var r = l.getRange();
|
||||
return (line > r.getStart().getLine() || line == r.getStart().getLine() && col >= r.getStart().getCharacter()) &&
|
||||
(line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter());
|
||||
}
|
||||
private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); }
|
||||
private static IJavaProject project(String uri) {
|
||||
Path path;
|
||||
try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; }
|
||||
IJavaProject best = null;
|
||||
int length = -1;
|
||||
for (var p : ResourcesPlugin.getWorkspace().getRoot().getProjects()) {
|
||||
if (!p.isOpen() || p.getLocation() == null) continue;
|
||||
var candidate = JavaCore.create(p);
|
||||
Path root;
|
||||
try { root = p.getLocation().toFile().getCanonicalFile().toPath(); } catch (java.io.IOException e) { continue; }
|
||||
if (candidate.exists() && path.startsWith(root) && root.getNameCount() > length) {
|
||||
best = candidate; length = root.getNameCount();
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
private static Location location(String handle) throws JavaModelException {
|
||||
IJavaElement element = JavaCore.create(handle);
|
||||
if (element == null || !element.exists()) return null;
|
||||
Location result = JDTUtils.toLocation(element);
|
||||
if (result == null && element instanceof IMember member && member.getClassFile() != null)
|
||||
result = JDTUtils.toLocation(member.getClassFile());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
long revision = REVISION.get();
|
||||
String id = project.getHandleIdentifier();
|
||||
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);
|
||||
// Only unsaved contents differ from what the Java model reports for disk,
|
||||
// so merely opening or closing an editor keeps the snapshot.
|
||||
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
for (var entry : copies.entrySet()) {
|
||||
check(monitor);
|
||||
if (!entry.getValue().hasUnsavedChanges()) continue;
|
||||
digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String source = entry.getValue().getSource();
|
||||
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
String workingCopies = HexFormat.of().formatHex(digest.digest());
|
||||
Index cached = CACHE.get(id);
|
||||
if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) {
|
||||
boolean valid = true;
|
||||
for (var entry : cached.files.entrySet()) {
|
||||
check(monitor);
|
||||
if (!entry.getValue().equals(stamp(entry.getKey()))) { valid = false; break; }
|
||||
}
|
||||
if (valid) return cached;
|
||||
}
|
||||
Builder builder = new Builder(monitor);
|
||||
Set<String> units = new HashSet<>(), roots = new LinkedHashSet<>();
|
||||
int visited = 0;
|
||||
for (IPackageFragmentRoot root : project.getAllPackageFragmentRoots()) {
|
||||
check(monitor);
|
||||
if (root.getKind() == IPackageFragmentRoot.K_SOURCE) {
|
||||
if (root.getResource() != null && root.getResource().getLocationURI() != null)
|
||||
roots.add(root.getResource().getLocationURI().toString());
|
||||
if (root.getJavaProject().getProject().getLocationURI() != null)
|
||||
roots.add(root.getJavaProject().getProject().getLocationURI().toString());
|
||||
if (root.getResource() != null && root.getResource().getLocation() != null)
|
||||
builder.metadataDirectory(root.getResource().getLocation().toFile().toPath(), project);
|
||||
for (IJavaElement child : root.getChildren()) {
|
||||
if (!(child instanceof IPackageFragment pkg)) continue;
|
||||
for (ICompilationUnit unit : pkg.getCompilationUnits()) {
|
||||
check(monitor);
|
||||
unit = copies.getOrDefault(unit.getPrimary().getHandleIdentifier(), unit);
|
||||
if (!units.add(unit.getHandleIdentifier())) continue;
|
||||
if (++visited > MAX_UNITS || builder.entries.size() >= MAX_ENTRIES) { builder.incomplete = true; break; }
|
||||
String source = unit.getSource();
|
||||
if (source == null) continue;
|
||||
if (source.length() > MAX_BYTES) { builder.incomplete = true; continue; }
|
||||
// Cheap lexical filter only chooses candidates; all accepted symbols
|
||||
// below are verified by JDT bindings, including constants and imports.
|
||||
if (source.contains("ConfigurationProperties") || source.contains("Value") || source.contains("getProperty") || source.contains("getRequiredProperty"))
|
||||
builder.parse(unit);
|
||||
}
|
||||
}
|
||||
} else if (root.isArchive()) {
|
||||
builder.metadata(root, project);
|
||||
}
|
||||
}
|
||||
// Resource roots are not necessarily Java package roots (e.g. Maven resources).
|
||||
// Return project locations as well; the client excludes nested independent modules.
|
||||
roots.add(project.getProject().getLocationURI().toString());
|
||||
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
|
||||
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet());
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
CACHE.remove(CACHE.keySet().iterator().next());
|
||||
CACHE.put(id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Relaxed matching is reserved for binder/metadata entries. @Value and
|
||||
* Environment lookups retain their literal names; dots remain boundaries. */
|
||||
static boolean matches(String pattern, String key, boolean relaxed) {
|
||||
if (!relaxed) return pattern.equals(key);
|
||||
List<String> a = parts(pattern), b = parts(key);
|
||||
if (a.size() != b.size()) return false;
|
||||
for (int i = 0; i < a.size(); i++) if (!a.get(i).equals("*") && !a.get(i).equals(b.get(i))) return false;
|
||||
return true;
|
||||
}
|
||||
private static List<String> parts(String key) {
|
||||
List<String> result = new ArrayList<>();
|
||||
StringBuilder part = new StringBuilder();
|
||||
for (int i = 0; i < key.length(); i++) {
|
||||
char ch = key.charAt(i);
|
||||
if (ch == '[') {
|
||||
if (!part.isEmpty()) { result.add(canonical(part.toString())); part.setLength(0); }
|
||||
int end = key.indexOf(']', i + 1);
|
||||
if (end < 0) { part.append(key.substring(i)); break; }
|
||||
result.add(canonical(key.substring(i + 1, end))); i = end;
|
||||
} else if (ch == '.') {
|
||||
if (!part.isEmpty() || i == 0 || key.charAt(i - 1) != ']') result.add(canonical(part.toString()));
|
||||
part.setLength(0);
|
||||
} else part.append(ch);
|
||||
}
|
||||
if (!part.isEmpty()) result.add(canonical(part.toString()));
|
||||
return result;
|
||||
}
|
||||
private static String canonical(String key) {
|
||||
return key.replace("-", "").replace("_", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class Builder {
|
||||
final IProgressMonitor monitor;
|
||||
final List<Entry> entries = new ArrayList<>();
|
||||
final Map<Path, FileStamp> files = new LinkedHashMap<>();
|
||||
boolean incomplete;
|
||||
int parsed;
|
||||
Builder(IProgressMonitor monitor) { this.monitor = monitor; }
|
||||
void add(String key, IJavaElement element) {
|
||||
if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
|
||||
if (element != null) entries.add(new Entry(key, element.getHandleIdentifier(), null, true));
|
||||
}
|
||||
void use(String key, ICompilationUnit unit, ASTNode node) {
|
||||
if (key.length() > 1024 || entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
|
||||
if (key.isBlank() || key.contains("${") || key.contains("#{")) return;
|
||||
try { entries.add(new Entry(key, "", JDTUtils.toLocation(unit, node.getStartPosition(), node.getLength()), false)); }
|
||||
catch (JavaModelException e) { incomplete = true; }
|
||||
}
|
||||
void parse(ICompilationUnit unit) {
|
||||
check(monitor);
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(unit);
|
||||
parser.setResolveBindings(true);
|
||||
parser.setWorkingCopyOwner(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner());
|
||||
parser.setBindingsRecovery(false);
|
||||
CompilationUnit tree = (CompilationUnit) parser.createAST(monitor);
|
||||
parsed++;
|
||||
tree.accept(new ASTVisitor() {
|
||||
@Override public void preVisit(ASTNode node) { check(monitor); }
|
||||
@Override public boolean visit(NormalAnnotation node) { annotation(node); return true; }
|
||||
@Override public boolean visit(SingleMemberAnnotation node) { annotation(node); return true; }
|
||||
@Override public boolean visit(MarkerAnnotation node) { annotation(node); return true; }
|
||||
private void annotation(Annotation node) {
|
||||
IAnnotationBinding binding = node.resolveAnnotationBinding();
|
||||
if (binding == null) return;
|
||||
String name = binding.getAnnotationType().getQualifiedName();
|
||||
if (name.equals(PROPERTIES)) {
|
||||
String prefix = annotationValue(binding, "prefix");
|
||||
if (prefix == null || prefix.isEmpty()) prefix = annotationValue(binding, "value");
|
||||
if (prefix == null) prefix = "";
|
||||
ASTNode owner = node.getParent();
|
||||
if (owner instanceof AbstractTypeDeclaration type) {
|
||||
bind(prefix, type.resolveBinding(), new HashSet<>(), 0);
|
||||
} else if (owner instanceof MethodDeclaration method && method.resolveBinding() != null) {
|
||||
add(prefix, method.resolveBinding().getJavaElement());
|
||||
bind(prefix, method.resolveBinding().getReturnType(), new HashSet<>(), 0);
|
||||
}
|
||||
} else if (name.equals(VALUE)) {
|
||||
String value = annotationValue(binding, "value");
|
||||
ASTNode expression = node;
|
||||
if (node instanceof SingleMemberAnnotation single) expression = single.getValue();
|
||||
else if (node instanceof NormalAnnotation normal) {
|
||||
for (Object pair : normal.values()) if (pair instanceof MemberValuePair p && p.getName().getIdentifier().equals("value")) expression = p.getValue();
|
||||
}
|
||||
if (value != null) placeholders(value, unit, expression);
|
||||
}
|
||||
}
|
||||
@Override public boolean visit(MethodInvocation node) {
|
||||
IMethodBinding method = node.resolveMethodBinding();
|
||||
if (method != null && (method.getName().equals("getProperty") || method.getName().equals("getRequiredProperty")) &&
|
||||
subtype(method.getDeclaringClass(), RESOLVER, new HashSet<>()) && !node.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) node.arguments().get(0);
|
||||
Object constant = arg.resolveConstantExpressionValue();
|
||||
if (constant instanceof String key) use(key, unit, arg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
void placeholders(String text, ICompilationUnit unit, ASTNode node) {
|
||||
// Include nested default placeholders, but never a dynamically composed name.
|
||||
for (int at = text.indexOf("${"); at >= 0; at = text.indexOf("${", at + 2)) {
|
||||
int end = at + 2;
|
||||
while (end < text.length() && text.charAt(end) != ':' && text.charAt(end) != '}') end++;
|
||||
if (end < text.length()) use(text.substring(at + 2, end), unit, node);
|
||||
}
|
||||
}
|
||||
void bind(String prefix, ITypeBinding type, Set<String> visiting, int depth) {
|
||||
check(monitor);
|
||||
if (type == null || type.isRecovered() || type.isPrimitive() || type.isEnum()) return;
|
||||
String qualified = type.getErasure().getQualifiedName();
|
||||
if (qualified.startsWith("java.") || qualified.startsWith("javax.")) return;
|
||||
if (depth > 12 || !visiting.add(type.getKey())) return;
|
||||
try {
|
||||
if (!prefix.isEmpty()) add(prefix, type.getJavaElement());
|
||||
List<IMethodBinding> constructors = Arrays.stream(type.getDeclaredMethods())
|
||||
.filter(m -> m.isConstructor() && !Modifier.isPrivate(m.getModifiers())).toList();
|
||||
for (IMethodBinding constructor : constructors) {
|
||||
boolean explicit = Arrays.stream(constructor.getAnnotations()).anyMatch(a ->
|
||||
a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.ConstructorBinding") ||
|
||||
a.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.ConstructorBinding"));
|
||||
if (!explicit && constructors.size() != 1) continue;
|
||||
String[] parameters = constructor.getParameterNames();
|
||||
for (int i = 0; i < parameters.length; i++) {
|
||||
String name = parameters[i];
|
||||
for (IAnnotationBinding annotation : constructor.getParameterAnnotations(i)) {
|
||||
if (annotation.getAnnotationType().getQualifiedName().equals("org.springframework.boot.context.properties.bind.Name")) {
|
||||
String alias = annotationValue(annotation, "value");
|
||||
if (alias != null) name = alias;
|
||||
}
|
||||
}
|
||||
// Prefer fields/accessors for ordinary parameters; aliases and
|
||||
// constructor-only properties still have a navigable declaration.
|
||||
final String parameterName = name;
|
||||
if (Arrays.stream(type.getDeclaredFields()).noneMatch(f -> f.getName().equals(parameterName))) {
|
||||
add(join(prefix, name), constructor.getJavaElement());
|
||||
nested(join(prefix, name), constructor.getParameterTypes()[i], constructor.getJavaElement(), visiting, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ITypeBinding current = type; current != null && !current.getQualifiedName().equals("java.lang.Object"); current = current.getSuperclass()) {
|
||||
for (IVariableBinding field : current.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic() || !names.add(field.getName())) continue;
|
||||
String key = join(prefix, field.getName());
|
||||
add(key, field.getJavaElement());
|
||||
nested(key, field.getType(), field.getJavaElement(), visiting, depth + 1);
|
||||
}
|
||||
for (IMethodBinding method : current.getDeclaredMethods()) {
|
||||
if (Modifier.isStatic(method.getModifiers()) || method.isConstructor()) continue;
|
||||
String name = method.getName();
|
||||
String property = null;
|
||||
ITypeBinding propertyType = null;
|
||||
if (name.startsWith("set") && name.length() > 3 && method.getParameterTypes().length == 1) {
|
||||
property = name.substring(3); propertyType = method.getParameterTypes()[0];
|
||||
} else if (name.startsWith("get") && name.length() > 3 && method.getParameterTypes().length == 0) {
|
||||
property = name.substring(3); propertyType = method.getReturnType();
|
||||
}
|
||||
if (property != null) {
|
||||
property = Character.toLowerCase(property.charAt(0)) + property.substring(1);
|
||||
String key = join(prefix, property);
|
||||
add(key, method.getJavaElement());
|
||||
if (names.add(property)) nested(key, propertyType, method.getJavaElement(), visiting, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { visiting.remove(type.getKey()); }
|
||||
}
|
||||
void nested(String key, ITypeBinding type, IJavaElement owner, Set<String> visiting, int depth) {
|
||||
if (type == null) return;
|
||||
if (type.isArray()) { add(key + "[*]", owner); bind(key + "[*]", type.getElementType(), visiting, depth); return; }
|
||||
String raw = type.getErasure().getQualifiedName();
|
||||
if (subtype(type, "java.util.Map", new HashSet<>())) {
|
||||
add(key + ".*", owner);
|
||||
var args = type.getTypeArguments();
|
||||
if (args.length == 2) bind(key + ".*", args[1], visiting, depth);
|
||||
} else if (subtype(type, "java.util.Collection", new HashSet<>())) {
|
||||
add(key + "[*]", owner);
|
||||
var args = type.getTypeArguments();
|
||||
if (args.length == 1) bind(key + "[*]", args[0], visiting, depth);
|
||||
} else if (!type.isPrimitive() && !type.isEnum() && !raw.startsWith("java.") && !raw.startsWith("javax.")) {
|
||||
bind(key, type, visiting, depth);
|
||||
}
|
||||
}
|
||||
void metadata(IPackageFragmentRoot root, IJavaProject project) {
|
||||
check(monitor);
|
||||
Path path = root.getPath().toFile().toPath();
|
||||
if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null)
|
||||
path = root.getResource().getLocation().toFile().toPath();
|
||||
files.put(path, stamp(path));
|
||||
if (!Files.isRegularFile(path)) return;
|
||||
try (ZipFile zip = new ZipFile(path.toFile())) {
|
||||
for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
|
||||
var entry = zip.getEntry("META-INF/" + filename);
|
||||
if (entry == null) continue;
|
||||
if (entry.getSize() > MAX_BYTES) { incomplete = true; continue; }
|
||||
JsonObject metadata;
|
||||
try (var stream = zip.getInputStream(entry)) {
|
||||
byte[] bytes = stream.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) { incomplete = true; continue; }
|
||||
metadata = JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
metadataObject(metadata, project);
|
||||
}
|
||||
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
|
||||
}
|
||||
void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException {
|
||||
JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray();
|
||||
for (JsonElement value : groups) {
|
||||
check(monitor);
|
||||
JsonObject group = value.getAsJsonObject();
|
||||
String name = string(group, "name"), source = string(group, "sourceType");
|
||||
if (source == null) source = string(group, "type");
|
||||
if (name != null && source != null) add(name, project.findType(source.replace('$', '.'), monitor));
|
||||
}
|
||||
if (!metadata.has("properties")) return;
|
||||
for (JsonElement value : metadata.getAsJsonArray("properties")) {
|
||||
check(monitor);
|
||||
if (entries.size() >= MAX_ENTRIES) { incomplete = true; return; }
|
||||
JsonObject property = value.getAsJsonObject();
|
||||
String key = string(property, "name"), source = string(property, "sourceType"), prefix = "";
|
||||
if (key == null) continue;
|
||||
JsonObject owner = null;
|
||||
for (JsonElement groupValue : groups) {
|
||||
JsonObject group = groupValue.getAsJsonObject();
|
||||
String name = string(group, "name");
|
||||
if (name != null && key.startsWith(name + ".") && name.length() > prefix.length()) {
|
||||
prefix = name; owner = group;
|
||||
}
|
||||
}
|
||||
if (source == null && owner != null) source = string(owner, "type");
|
||||
if (source == null && owner != null) source = string(owner, "sourceType");
|
||||
if (source == null) continue;
|
||||
IType type = project.findType(source.replace('$', '.'), monitor);
|
||||
if (type == null) continue;
|
||||
String leaf = key.substring(prefix.isEmpty() ? 0 : prefix.length() + 1);
|
||||
IJavaElement target = member(type, leaf, new HashSet<>());
|
||||
if (target == null && owner != null && string(owner, "sourceMethod") != null) {
|
||||
String declaring = string(owner, "sourceType");
|
||||
IType factory = declaring == null ? null : project.findType(declaring.replace('$', '.'), monitor);
|
||||
if (factory != null) {
|
||||
String methodName = string(owner, "sourceMethod").split("\\(", 2)[0];
|
||||
for (IMethod method : factory.getMethods()) if (method.getElementName().equals(methodName)) { target = method; break; }
|
||||
}
|
||||
}
|
||||
add(key, target == null ? type : target);
|
||||
}
|
||||
}
|
||||
void metadataDirectory(Path directory, IJavaProject project) {
|
||||
for (String name : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
|
||||
Path file = directory.resolve("META-INF").resolve(name);
|
||||
files.put(file, stamp(file));
|
||||
if (!Files.isRegularFile(file)) continue;
|
||||
try {
|
||||
if (Files.size(file) > MAX_BYTES) { incomplete = true; continue; }
|
||||
try (var stream = Files.newInputStream(file)) {
|
||||
byte[] bytes = stream.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) { incomplete = true; continue; }
|
||||
metadataObject(JsonParser.parseString(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(), project);
|
||||
}
|
||||
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
private static IJavaElement member(IType type, String key, Set<String> seen) throws JavaModelException {
|
||||
if (type == null || !seen.add(type.getHandleIdentifier())) return null;
|
||||
String part = key.split("\\.", 2)[0];
|
||||
for (IField field : type.getFields()) if (canonical(field.getElementName()).equals(canonical(part))) {
|
||||
if (!key.contains(".")) return field;
|
||||
String signature = Signature.toString(Signature.getTypeErasure(field.getTypeSignature()));
|
||||
String[][] resolved = type.resolveType(signature);
|
||||
IType nested = type.getJavaProject().findType(signature);
|
||||
if (nested == null && resolved != null && resolved.length == 1)
|
||||
nested = type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]);
|
||||
IJavaElement found = member(nested, key.substring(key.indexOf('.') + 1), seen);
|
||||
return found == null ? field : found;
|
||||
}
|
||||
for (IMethod method : type.getMethods()) {
|
||||
String name = method.getElementName();
|
||||
if (name.length() > 3 && (name.startsWith("get") || name.startsWith("set")) && canonical(name.substring(3)).equals(canonical(part))) return method;
|
||||
}
|
||||
String parent = type.getSuperclassName();
|
||||
if (parent != null) {
|
||||
String[][] resolved = type.resolveType(parent);
|
||||
if (resolved != null && resolved.length == 1)
|
||||
return member(type.getJavaProject().findType(resolved[0][0] + "." + resolved[0][1]), key, seen);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static String string(JsonObject object, String key) {
|
||||
return object.has(key) && object.get(key).isJsonPrimitive() ? object.get(key).getAsString() : null;
|
||||
}
|
||||
private static String annotationValue(IAnnotationBinding binding, String name) {
|
||||
for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs())
|
||||
if (pair.getName().equals(name) && pair.getValue() instanceof String s) return s;
|
||||
return null;
|
||||
}
|
||||
private static String join(String prefix, String name) { return prefix.isEmpty() ? name : prefix + "." + name; }
|
||||
private static boolean subtype(ITypeBinding type, String name, Set<String> seen) {
|
||||
if (type == null || !seen.add(type.getKey())) return false;
|
||||
if (type.getErasure().getQualifiedName().equals(name)) return true;
|
||||
for (ITypeBinding face : type.getInterfaces()) if (subtype(face, name, seen)) return true;
|
||||
return subtype(type.getSuperclass(), name, seen);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package ai.openidea.hierarchy;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.internal.core.manipulation.JavaElementLabelsCore;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.RefactoringAvailabilityTesterCore;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor;
|
||||
import org.eclipse.jdt.internal.corext.refactoring.structure.PushDownRefactoringProcessor.MemberActionInfo;
|
||||
import org.eclipse.jdt.internal.corext.util.JdtFlags;
|
||||
import org.eclipse.jdt.ls.core.internal.ChangeUtil;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JDTUtils;
|
||||
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
|
||||
import org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager;
|
||||
import org.eclipse.ltk.core.refactoring.Change;
|
||||
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
|
||||
import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry;
|
||||
import org.eclipse.ltk.core.refactoring.participants.ProcessorBasedRefactoring;
|
||||
import org.eclipse.ltk.core.refactoring.participants.RefactoringProcessor;
|
||||
|
||||
/**
|
||||
* Pull Members Up / Push Members Down with JDT's own processors.
|
||||
*
|
||||
* <p>{@code openidea.java.hierarchy.members(uri, line, column, kind)} describes the class at the
|
||||
* position: its movable members and, for pull up, the supertypes in project sources.
|
||||
* {@code openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)} checks the
|
||||
* chosen move and returns its problems and the edit, which is never applied here. {@code kind}
|
||||
* is {@code pullUp} or {@code pushDown}; {@code choices} are {@code {id, abstract}} objects whose
|
||||
* ids are the handle identifiers returned by {@code members}.
|
||||
*/
|
||||
public final class HierarchyCommand implements IDelegateCommandHandler {
|
||||
static final String MEMBERS = "openidea.java.hierarchy.members";
|
||||
static final String REFACTOR = "openidea.java.hierarchy.refactor";
|
||||
|
||||
private static final long LABEL = JavaElementLabelsCore.M_PARAMETER_TYPES | JavaElementLabelsCore.M_APP_RETURNTYPE
|
||||
| JavaElementLabelsCore.F_APP_TYPE_SIGNATURE;
|
||||
|
||||
@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 pullUp = switch ((String) arguments.get(3)) {
|
||||
case "pullUp" -> true;
|
||||
case "pushDown" -> false;
|
||||
default -> throw new IllegalArgumentException("unknown hierarchy refactoring " + arguments.get(3));
|
||||
};
|
||||
ICompilationUnit unit = JDTUtils.resolveCompilationUnit(uri);
|
||||
if (unit == null || !unit.exists()) return refused("The file is not part of a Java project");
|
||||
IMember start = memberAt(unit, JsonRpcHelpers.toOffset(unit, line, column));
|
||||
IMember[] members = start == null ? null : selection(start, pullUp);
|
||||
if (members == null) {
|
||||
return refused(pullUp ? "Caret should be inside a class that has members to pull up"
|
||||
: "Caret should be inside a class that has members to push down");
|
||||
}
|
||||
if (MEMBERS.equals(command)) return pullUp ? pullUpMembers(unit, members, monitor) : pushDownMembers(members, monitor);
|
||||
if (REFACTOR.equals(command)) {
|
||||
String target = (String) arguments.get(4);
|
||||
List<Choice> choices = new ArrayList<>();
|
||||
for (Object o : (List<?>) arguments.get(5)) {
|
||||
Map<?, ?> map = (Map<?, ?>) o;
|
||||
IJavaElement element = JavaCore.create((String) map.get("id"));
|
||||
if (!(element instanceof IMember member) || !member.exists()) return refused("The class changed; open the dialog again");
|
||||
choices.add(new Choice(member, Boolean.TRUE.equals(map.get("abstract"))));
|
||||
}
|
||||
if (choices.isEmpty()) return refused("No members selected");
|
||||
// Started from the class, the processors know it even when no member is moved
|
||||
// (a method only declared abstract in the destination).
|
||||
IType declaring = members[0] instanceof IType type ? type : members[0].getDeclaringType();
|
||||
IMember[] owner = { declaring };
|
||||
return pullUp ? pullUp(unit, owner, target, choices, monitor) : pushDown(owner, choices, monitor);
|
||||
}
|
||||
throw new IllegalArgumentException("unknown command " + command);
|
||||
}
|
||||
|
||||
private record Choice(IMember member, boolean makeAbstract) {}
|
||||
|
||||
/** The innermost member at the offset, or the type itself for an initializer. */
|
||||
private static IMember memberAt(ICompilationUnit unit, int offset) throws JavaModelException {
|
||||
IJavaElement element = unit.getElementAt(offset);
|
||||
while (element != null && !(element instanceof IMember)) element = element.getParent();
|
||||
if (element instanceof IInitializer initializer) return initializer.getDeclaringType();
|
||||
return (IMember) element;
|
||||
}
|
||||
|
||||
/** The members to start from: the one at the caret when it can move, otherwise its class. */
|
||||
private static IMember[] selection(IMember start, boolean pullUp) throws JavaModelException {
|
||||
for (IMember member = start; member != null; member = member.getDeclaringType()) {
|
||||
if (member.isBinary() || member.isReadOnly()) return null;
|
||||
if (pullUp ? RefactoringAvailabilityTesterCore.isPullUpAvailable(member)
|
||||
: RefactoringAvailabilityTesterCore.isPushDownAvailable(member)) return new IMember[] { member };
|
||||
if (member instanceof IType type && (pullUp ? RefactoringAvailabilityTesterCore.getPullUpMembers(type)
|
||||
: RefactoringAvailabilityTesterCore.getPushDownMembers(type)).length > 0) {
|
||||
return new IMember[] { type };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object pullUpMembers(ICompilationUnit unit, IMember[] members, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
|
||||
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
|
||||
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
|
||||
IType[] candidates = processor.getCandidateTypes(new RefactoringStatus(), monitor);
|
||||
cancelled(monitor);
|
||||
if (candidates.length == 0) {
|
||||
return refused("'" + processor.getDeclaringType().getElementName()
|
||||
+ "' has no superclass or interface in the project sources to pull members up to");
|
||||
}
|
||||
List<Map<String, Object>> targets = new ArrayList<>();
|
||||
for (IType type : candidates) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", type.getHandleIdentifier());
|
||||
row.put("name", type.getElementName());
|
||||
row.put("qualifiedName", type.getFullyQualifiedName('.'));
|
||||
row.put("interface", type.isInterface());
|
||||
targets.add(row);
|
||||
}
|
||||
Set<IMember> selected = Set.of(processor.getMembersToMove());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (IMember member : processor.getPullableMembersOfDeclaringType()) {
|
||||
boolean abstractable = member instanceof IMethod method && !JdtFlags.isStatic(method);
|
||||
rows.add(member(member, selected.contains(member), abstractable));
|
||||
}
|
||||
return info(processor.getDeclaringType(), targets, rows);
|
||||
}
|
||||
|
||||
private static Object pushDownMembers(IMember[] members, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PushDownRefactoringProcessor(members);
|
||||
RefactoringStatus status = new ProcessorBasedRefactoring(processor).checkInitialConditions(monitor);
|
||||
if (status.hasFatalError()) return refused(status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
|
||||
IType declaring = processor.getDeclaringType();
|
||||
List<Map<String, Object>> targets = new ArrayList<>();
|
||||
for (IType type : declaring.newTypeHierarchy(declaring.getJavaProject(), monitor).getSubtypes(declaring)) {
|
||||
if (type.isBinary()) continue;
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", type.getHandleIdentifier());
|
||||
row.put("name", type.getElementName());
|
||||
row.put("qualifiedName", type.getFullyQualifiedName('.'));
|
||||
row.put("interface", type.isInterface());
|
||||
targets.add(row);
|
||||
}
|
||||
cancelled(monitor);
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (MemberActionInfo info : processor.getMemberActionInfos()) {
|
||||
boolean abstractable = false;
|
||||
for (int action : info.getAvailableActions()) abstractable |= action == MemberActionInfo.PUSH_ABSTRACT_ACTION;
|
||||
rows.add(member(info.getMember(), info.isToBePushedDown(), abstractable));
|
||||
}
|
||||
return info(declaring, targets, rows);
|
||||
}
|
||||
|
||||
private static Object pullUp(ICompilationUnit unit, IMember[] members, String target, List<Choice> choices,
|
||||
IProgressMonitor monitor) throws Exception {
|
||||
if (!(JavaCore.create(target) instanceof IType destination) || !destination.exists()) {
|
||||
return refused("The destination class no longer exists");
|
||||
}
|
||||
var processor = new PullUpRefactoringProcessor(members, PreferenceManager.getCodeGenerationSettings(unit));
|
||||
List<IMember> moved = new ArrayList<>();
|
||||
List<IMethod> declaredAbstract = new ArrayList<>();
|
||||
List<IMethod> deleted = new ArrayList<>();
|
||||
for (Choice choice : choices) {
|
||||
// A method pulled up to an interface is declared there, as JDT's own wizard does.
|
||||
if (choice.member instanceof IMethod method && (choice.makeAbstract || destination.isInterface())) {
|
||||
declaredAbstract.add(method);
|
||||
} else {
|
||||
moved.add(choice.member);
|
||||
if (choice.member instanceof IMethod method) deleted.add(method);
|
||||
}
|
||||
}
|
||||
processor.setMembersToMove(moved.toArray(IMember[]::new));
|
||||
processor.setAbstractMethods(declaredAbstract.toArray(IMethod[]::new));
|
||||
processor.setDeletedMethods(deleted.toArray(IMethod[]::new));
|
||||
processor.setDestinationType(destination);
|
||||
processor.setCreateMethodStubs(true);
|
||||
return run(processor, monitor);
|
||||
}
|
||||
|
||||
private static Object pushDown(IMember[] members, List<Choice> choices, IProgressMonitor monitor) throws Exception {
|
||||
var processor = new PushDownRefactoringProcessor(members);
|
||||
var refactoring = new ProcessorBasedRefactoring(processor);
|
||||
RefactoringStatus initial = refactoring.checkInitialConditions(monitor);
|
||||
if (initial.hasFatalError()) return result(initial, null);
|
||||
Map<IMember, Choice> chosen = new LinkedHashMap<>();
|
||||
for (Choice choice : choices) chosen.put(choice.member, choice);
|
||||
for (MemberActionInfo info : processor.getMemberActionInfos()) {
|
||||
Choice choice = chosen.get(info.getMember());
|
||||
if (choice == null) info.setAction(MemberActionInfo.NO_ACTION);
|
||||
else info.setAction(choice.makeAbstract && info.getMember() instanceof IMethod
|
||||
? MemberActionInfo.PUSH_ABSTRACT_ACTION : MemberActionInfo.PUSH_DOWN_ACTION);
|
||||
}
|
||||
RefactoringStatus status = refactoring.checkFinalConditions(monitor);
|
||||
initial.merge(status);
|
||||
return finish(refactoring, initial, monitor);
|
||||
}
|
||||
|
||||
private static Object run(RefactoringProcessor processor, IProgressMonitor monitor) throws Exception {
|
||||
var refactoring = new ProcessorBasedRefactoring(processor);
|
||||
RefactoringStatus status = refactoring.checkAllConditions(monitor);
|
||||
return finish(refactoring, status, monitor);
|
||||
}
|
||||
|
||||
private static Object finish(ProcessorBasedRefactoring refactoring, RefactoringStatus status, IProgressMonitor monitor)
|
||||
throws Exception {
|
||||
cancelled(monitor);
|
||||
if (status.hasFatalError()) return result(status, null);
|
||||
Change change = refactoring.createChange(monitor);
|
||||
cancelled(monitor);
|
||||
return result(status, ChangeUtil.convertToWorkspaceEdit(change));
|
||||
}
|
||||
|
||||
private static Map<String, Object> member(IMember member, boolean selected, boolean abstractable) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", member.getHandleIdentifier());
|
||||
row.put("label", JavaElementLabelsCore.getElementLabel(member, LABEL));
|
||||
row.put("kind", member instanceof IMethod ? "method" : member instanceof IField ? "field" : "type");
|
||||
row.put("selected", selected);
|
||||
row.put("abstractable", abstractable);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Map<String, Object> info(IType type, List<Map<String, Object>> targets, List<Map<String, Object>> members) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("className", type.getElementName());
|
||||
out.put("targets", targets);
|
||||
out.put("members", members);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> result(RefactoringStatus status, Object edit) {
|
||||
List<Map<String, Object>> problems = new ArrayList<>();
|
||||
for (RefactoringStatusEntry entry : status.getEntries()) {
|
||||
String severity = switch (entry.getSeverity()) {
|
||||
case RefactoringStatus.FATAL -> "fatal";
|
||||
case RefactoringStatus.ERROR -> "error";
|
||||
case RefactoringStatus.WARNING -> "warning";
|
||||
default -> "info";
|
||||
};
|
||||
problems.add(Map.of("severity", severity, "message", entry.getMessage()));
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("problems", problems);
|
||||
out.put("edit", edit);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Map<String, Object> refused(String reason) {
|
||||
return Map.of("refused", reason);
|
||||
}
|
||||
|
||||
private static void cancelled(IProgressMonitor monitor) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
@@ -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
Block a user