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]>
Цей коміт міститься в:
Park Jong Hyun authored and GitHub committed 2026-11-04 14:55:42 +08:00
1 джерело 5b46844f88
коміт ed145e3046
39 files changed
+2008 -14

No files matched your search

+3 -2
Переглянути файл
@@ -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
+27 -1
Переглянути файл
@@ -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.
Бінарний файл не відображається.
+4
Переглянути файл
@@ -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>
+261
Переглянути файл
@@ -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();
}
}
+272
Переглянути файл
@@ -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)