Files
openidea/plugins/java-usages/tests/hierarchy.py
Park Jong Hyun ed145e3046 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 <81476595+fhrmrk@users.noreply.github.com>
2026-11-04 14:55:42 +08:00

273 lines
12 KiB
Python

"""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)