"""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('zooorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature')
(project / '.classpath').write_text('')
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)