дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(refactor): Java Pull Members Up / Push Members Down (#1183)
Refactor | Pull Members Up… and Push Members Down… for Java, from the Refactor menu, the editor context menu and Refactor This. A dialog picks the destination and the members (Make abstract / Keep abstract); problems are confirmed before anything changes; the edit is applied as one change or previewed first. JDT LS offers neither as a code action, so the app-owned JDT LS bundle (plugins/java-usages 1.3.0) adds two delegate commands that run JDT's PullUpRefactoringProcessor / PushDownRefactoringProcessor and return the problems and the edit without applying it, exposed as lsp_hierarchy_members / lsp_hierarchy_refactor (also over the remote host dispatcher). The bundled jar was rebuilt from source with JDT LS 1.63.0 and matches the recorded SHA-256. Co-authored-by: fhrmrk <[email protected]>
Цей коміт міститься в:
1 джерело
5b46844f88
коміт
ed145e3046
39 files changed
+2008
-14
No files matched your search
@@ -2,9 +2,10 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.2.1
|
||||
Bundle-Version: 1.3.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson
|
||||
org.eclipse.lsp4j,org.eclipse.core.resources,com.google.gson,
|
||||
org.eclipse.ltk.core.refactoring
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.2.1.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.3.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -73,6 +73,8 @@ Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
|
||||
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
|
||||
unrelated content and non-Java resource changes; its reproducible SHA-256 is
|
||||
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
|
||||
Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SHA-256 is
|
||||
`cca4cc385ace286111730d2a6164333b8958d74d69631548eadca8863255c24a`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
@@ -101,6 +103,7 @@ not rebuild this jar. A maintainer must:
|
||||
python3 plugins/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar
|
||||
python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json
|
||||
OPENIDEA_USAGES_INDEX_STRESS=1 python3 plugins/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json
|
||||
python3 plugins/java-usages/tests/hierarchy.py /path/to/jdtls
|
||||
```
|
||||
|
||||
The harnesses select the JDT LS configuration for the current platform. The first
|
||||
@@ -112,6 +115,24 @@ method references, receiver/array reads and a real cancellation response (`-3280
|
||||
Use the actual desktop app to check read/write toggles, mutual toggle behavior,
|
||||
Find transfer, persistence and unfiltered non-Java counts with the same fixture.
|
||||
|
||||
`hierarchy.py` runs Pull Members Up and Push Members Down: member and destination
|
||||
listing, moving, declaring abstract, pulling up to an interface, conflicts reported
|
||||
before any change, and a class without supertypes. Each edit is applied to a copy of
|
||||
the fixture and compiled with javac.
|
||||
|
||||
## Class-hierarchy refactorings
|
||||
|
||||
`openidea.java.hierarchy.members(uri, line, column, kind)` describes the class at the
|
||||
position for `kind` `pullUp` or `pushDown`: its movable members (handle id, label,
|
||||
kind, preselected, can be declared abstract) and the destination supertypes, or the
|
||||
direct subtypes the members are pushed into. A position on a member preselects it.
|
||||
`openidea.java.hierarchy.refactor(uri, line, column, kind, target, choices)` runs
|
||||
JDT's `PullUpRefactoringProcessor` / `PushDownRefactoringProcessor` with the chosen
|
||||
`{id, abstract}` members and returns `{problems, edit}`. Nothing is applied by the
|
||||
server; the app shows the problems, then applies the edit as one undoable change.
|
||||
A method pulled up to an interface is always declared there, as in JDT's wizard.
|
||||
Either command answers `{refused: reason}` when the position can't be refactored.
|
||||
|
||||
## Review and manual reproduction
|
||||
|
||||
See [the English reproduction guide](tests/REPRODUCE.md) for a small Java/Lombok
|
||||
@@ -129,6 +150,11 @@ This is an app-owned JDT LS extension, not a standard LSP feature. It uses:
|
||||
objects (`isIncludeAccessors`).
|
||||
- `JDTUtils.resolveTypeRoot`, `findElementAtSelection`.
|
||||
- `org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil`.
|
||||
- `org.eclipse.jdt.internal.corext.refactoring.structure.PullUpRefactoringProcessor`,
|
||||
`PushDownRefactoringProcessor`, `RefactoringAvailabilityTesterCore`, `JdtFlags`,
|
||||
`JavaElementLabelsCore`, `PreferenceManager.getCodeGenerationSettings`,
|
||||
`JsonRpcHelpers.toOffset`, `JDTUtils.resolveCompilationUnit` and
|
||||
`ChangeUtil.convertToWorkspaceEdit`.
|
||||
- Public JDT model/DOM APIs for exact target identity and source-expression context.
|
||||
|
||||
The fingerprint protects native target discovery, not all binary compatibility.
|
||||
|
||||
Бінарний файл не відображається.
@@ -7,5 +7,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
</delegateCommandHandler>
|
||||
</extension>
|
||||
</plugin>
|
||||
@@ -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,272 @@
|
||||
"""Pull Members Up / Push Members Down against a real JDT LS. Argument: jdtls home.
|
||||
|
||||
Each case computes the edit from the same unchanged sources, applies it to a copy
|
||||
of the project and compiles the copy with javac.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
home = pathlib.Path(sys.argv[1]).resolve()
|
||||
bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-java-usages.jar'
|
||||
work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-hierarchy-'))
|
||||
config = work / 'config'
|
||||
config.mkdir()
|
||||
system, machine = platform.system(), platform.machine().lower()
|
||||
config_name = {'Windows': 'config_win', 'Darwin': 'config_mac'}.get(system, 'config_linux')
|
||||
config_names = ([config_name + '_arm'] if machine in ('arm64', 'aarch64') else []) + [config_name]
|
||||
config_source = next(home / name / 'config.ini' for name in config_names if (home / name / 'config.ini').is_file())
|
||||
(config / 'config.ini').write_bytes(config_source.read_bytes())
|
||||
project = work / 'project'
|
||||
(project / 'src/zoo').mkdir(parents=True)
|
||||
(project / '.project').write_text('<projectDescription><name>zoo</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>')
|
||||
(project / '.classpath').write_text('<classpath><classpathentry kind="src" path="src"/><classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/><classpathentry kind="output" path="bin"/></classpath>')
|
||||
sources = {
|
||||
'Animal.java': '''package zoo;
|
||||
|
||||
public abstract class Animal {
|
||||
protected String name = "animal";
|
||||
|
||||
public String describe() {
|
||||
return "I am " + name;
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Speaker.java': '''package zoo;
|
||||
|
||||
public interface Speaker {
|
||||
}
|
||||
''',
|
||||
'Dog.java': '''package zoo;
|
||||
|
||||
public class Dog extends Animal implements Speaker {
|
||||
private int tail = 1;
|
||||
|
||||
public String bark() {
|
||||
return "Woof";
|
||||
}
|
||||
|
||||
public int wag() {
|
||||
return tail * 2;
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Cat.java': '''package zoo;
|
||||
|
||||
public class Cat extends Animal {
|
||||
public String purr() {
|
||||
return describe() + " purr";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Lonely.java': '''package zoo;
|
||||
|
||||
public class Lonely {
|
||||
public void alone() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
public class Use {
|
||||
String run(Dog dog, Animal animal) {
|
||||
return dog.bark() + dog.wag() + animal.describe();
|
||||
}
|
||||
}
|
||||
''',
|
||||
}
|
||||
for name, text in sources.items():
|
||||
(project / 'src/zoo' / name).write_text(text)
|
||||
uri = lambda name: (project / 'src/zoo' / name).as_uri()
|
||||
|
||||
log = (work / 'stderr.log').open('w')
|
||||
proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home / 'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work / 'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log)
|
||||
messages = queue.Queue()
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def send(message):
|
||||
data = json.dumps(message).encode()
|
||||
with lock:
|
||||
proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data)
|
||||
proc.stdin.flush()
|
||||
|
||||
|
||||
def read():
|
||||
while True:
|
||||
headers = {}
|
||||
while True:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
return
|
||||
if line == b'\r\n':
|
||||
break
|
||||
k, v = line.decode().split(':', 1)
|
||||
headers[k.lower()] = v.strip()
|
||||
message = json.loads(proc.stdout.read(int(headers['content-length'])))
|
||||
if 'method' in message and 'id' in message:
|
||||
send({'jsonrpc': '2.0', 'id': message['id'], 'result': [] if message['method'] == 'workspace/configuration' else None})
|
||||
else:
|
||||
messages.put(message)
|
||||
|
||||
|
||||
threading.Thread(target=read, daemon=True).start()
|
||||
seq = 0
|
||||
|
||||
|
||||
def request(method, params):
|
||||
global seq
|
||||
seq += 1
|
||||
send({'jsonrpc': '2.0', 'id': seq, 'method': method, 'params': params})
|
||||
deadline = time.monotonic() + 180
|
||||
while time.monotonic() < deadline:
|
||||
msg = messages.get(timeout=max(1, deadline - time.monotonic()))
|
||||
if msg.get('id') == seq:
|
||||
if 'error' in msg:
|
||||
raise RuntimeError(msg['error'])
|
||||
return msg.get('result')
|
||||
raise TimeoutError(method)
|
||||
|
||||
|
||||
def position(name, needle):
|
||||
lines = sources[name].splitlines()
|
||||
line = next(i for i, t in enumerate(lines) if needle in t)
|
||||
return line, lines[line].index(needle)
|
||||
|
||||
|
||||
def command(name, *args):
|
||||
return request('workspace/executeCommand', {'command': name, 'arguments': list(args)})
|
||||
|
||||
|
||||
def members(file, needle, kind):
|
||||
line, col = position(file, needle)
|
||||
return command('openidea.java.hierarchy.members', uri(file), line, col, kind)
|
||||
|
||||
|
||||
def refactor(file, needle, kind, target, choices):
|
||||
line, col = position(file, needle)
|
||||
return command('openidea.java.hierarchy.refactor', uri(file), line, col, kind, target, choices)
|
||||
|
||||
|
||||
def offset(text, pos):
|
||||
lines = text.split('\n')
|
||||
return sum(len(l) + 1 for l in lines[:pos['line']]) + pos['character']
|
||||
|
||||
|
||||
def apply(edit):
|
||||
"""The project after `edit`, compiled; returns the edited sources."""
|
||||
per_file = {}
|
||||
for u, edits in (edit.get('changes') or {}).items():
|
||||
per_file.setdefault(u, []).extend(edits)
|
||||
for change in edit.get('documentChanges') or []:
|
||||
assert 'kind' not in change, change
|
||||
per_file.setdefault(change['textDocument']['uri'], []).extend(change['edits'])
|
||||
out = dict(sources)
|
||||
for u, edits in per_file.items():
|
||||
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(u).path)).name
|
||||
text = out[name]
|
||||
for e in sorted(edits, key=lambda e: offset(text, e['range']['start']), reverse=True):
|
||||
text = text[:offset(text, e['range']['start'])] + e['newText'] + text[offset(text, e['range']['end']):]
|
||||
out[name] = text
|
||||
copy = work / f'compile{seq}'
|
||||
(copy / 'zoo').mkdir(parents=True)
|
||||
for name, text in out.items():
|
||||
(copy / 'zoo' / name).write_text(text)
|
||||
javac = subprocess.run(['javac', '-d', str(copy / 'bin'), *map(str, (copy / 'zoo').glob('*.java'))], capture_output=True, text=True)
|
||||
assert javac.returncode == 0, (javac.stderr, out)
|
||||
return out
|
||||
|
||||
|
||||
def body(text, name):
|
||||
return [l.strip() for l in text.splitlines() if name in l]
|
||||
|
||||
|
||||
try:
|
||||
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {'workspace': {'workspaceEdit': {'documentChanges': True}}}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
|
||||
commands = init['capabilities']['executeCommandProvider']['commands']
|
||||
assert 'openidea.java.hierarchy.members' in commands and 'openidea.java.hierarchy.refactor' in commands, commands
|
||||
send({'jsonrpc': '2.0', 'method': 'initialized', 'params': {}})
|
||||
for name, text in sources.items():
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': uri(name), 'languageId': 'java', 'version': 1, 'text': text}}})
|
||||
request('java/buildWorkspace', True)
|
||||
evidence = {}
|
||||
|
||||
# Members of Dog, with bark() preselected; Animal and Speaker are destinations.
|
||||
info = members('Dog.java', 'bark', 'pullUp')
|
||||
evidence['pullUpMembers'] = info
|
||||
assert info['className'] == 'Dog', info
|
||||
assert {t['name'] for t in info['targets']} == {'Animal', 'Speaker'}, info['targets']
|
||||
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
|
||||
assert rows['bark']['selected'] and not rows['wag']['selected'] and not rows['tail']['selected'], rows
|
||||
assert rows['bark']['abstractable'] and rows['bark']['kind'] == 'method' and rows['tail']['kind'] == 'field', rows
|
||||
animal = next(t for t in info['targets'] if t['name'] == 'Animal')
|
||||
speaker = next(t for t in info['targets'] if t['name'] == 'Speaker')
|
||||
|
||||
# Caret on the class name: nothing preselected.
|
||||
on_class = members('Dog.java', 'class Dog', 'pullUp')
|
||||
assert on_class['className'] == 'Dog' and not any(m['selected'] for m in on_class['members']), on_class
|
||||
|
||||
# Pull bark() up to Animal: moved, call sites unchanged, compiles.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': False}])
|
||||
evidence['pullUp'] = result
|
||||
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
|
||||
out = apply(result['edit'])
|
||||
assert 'bark()' in out['Animal.java'] and 'bark()' not in out['Dog.java'], out
|
||||
assert out['Use.java'] == sources['Use.java']
|
||||
|
||||
# Make abstract: declared in Animal, kept (and overridden) in Dog.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', animal['id'], [{'id': rows['bark']['id'], 'abstract': True}])
|
||||
evidence['pullUpAbstract'] = result
|
||||
out = apply(result['edit'])
|
||||
assert any('abstract' in l for l in body(out['Animal.java'], 'bark()')), out['Animal.java']
|
||||
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
|
||||
|
||||
# To an interface, a method is always declared, never moved.
|
||||
result = refactor('Dog.java', 'bark', 'pullUp', speaker['id'], [{'id': rows['bark']['id'], 'abstract': False}])
|
||||
evidence['pullUpInterface'] = result
|
||||
out = apply(result['edit'])
|
||||
assert body(out['Speaker.java'], 'bark()') == ['String bark();'] or body(out['Speaker.java'], 'bark()') == ['public String bark();'], out['Speaker.java']
|
||||
assert 'Woof' in out['Dog.java'] and '@Override' in out['Dog.java'], out['Dog.java']
|
||||
|
||||
# wag() uses Dog's private field: reported before anything changes.
|
||||
result = refactor('Dog.java', 'wag', 'pullUp', animal['id'], [{'id': rows['wag']['id'], 'abstract': False}])
|
||||
evidence['pullUpConflict'] = result
|
||||
assert any(p['severity'] in ('error', 'fatal') and 'tail' in p['message'] for p in result['problems']), result
|
||||
|
||||
# No superclass in the sources.
|
||||
refused = members('Lonely.java', 'alone', 'pullUp')
|
||||
evidence['pullUpRefused'] = refused
|
||||
assert 'refused' in refused, refused
|
||||
|
||||
# Push describe() down: copied into Dog and Cat, removed from Animal.
|
||||
info = members('Animal.java', 'describe', 'pushDown')
|
||||
evidence['pushDownMembers'] = info
|
||||
assert info['className'] == 'Animal' and {t['name'] for t in info['targets']} == {'Dog', 'Cat'}, info
|
||||
rows = {m['label'].split('(')[0].split(':')[0].strip(): m for m in info['members']}
|
||||
assert rows['describe']['selected'] and rows['describe']['abstractable'] and not rows['name']['abstractable'], rows
|
||||
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': True}])
|
||||
evidence['pushDownAbstract'] = result
|
||||
assert not [p for p in result['problems'] if p['severity'] in ('error', 'fatal')], result['problems']
|
||||
out = apply(result['edit'])
|
||||
assert any('abstract' in l for l in body(out['Animal.java'], 'describe()')), out['Animal.java']
|
||||
assert 'I am' in out['Dog.java'] and 'I am' in out['Cat.java'], out
|
||||
|
||||
# Without keeping a declaration, Use's animal.describe() breaks: JDT reports it.
|
||||
result = refactor('Animal.java', 'describe', 'pushDown', None, [{'id': rows['describe']['id'], 'abstract': False}])
|
||||
evidence['pushDownConflict'] = result
|
||||
assert any(p['severity'] in ('error', 'fatal') for p in result['problems']), result
|
||||
|
||||
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
print('hierarchy: all cases passed')
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=20)
|
||||
print('Evidence:', work, file=sys.stderr)
|
||||
Reference in new issue
Заблокувати користувача