mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
perf(java): find implementations in the project sources first (#1616)
JDT LS answers textDocument/implementation by building the type hierarchy over every dependency jar, so Go to Implementation and the implemented/overridden gutter markers were slow in projects with many dependencies. The app-owned JDT LS bundle (1.4.0) adds openidea.java.implementations: subtypes, or overriding methods with type arguments substituted and abstract ones left out, within the workspace's source folders only. ide-lsp asks it first and falls back to the standard request when the sources have none or the command is missing.
This commit is contained in:
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.3.0
|
||||
Bundle-Version: 1.4.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,
|
||||
|
||||
@@ -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.3.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.4.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.
|
||||
|
||||
@@ -75,6 +75,9 @@ 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`.
|
||||
Version 1.4.0 adds `openidea.java.implementations` (Go to Implementation in the workspace's
|
||||
source folders, before the standard request that also searches every dependency jar); its
|
||||
reproducible SHA-256 is `d7601493eda846811b4bb8bfe7f3d56e3234a45e663648210db3937cebc770a5`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,9 @@
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
|
||||
<command id="openidea.java.hierarchy.members"/>
|
||||
<command id="openidea.java.hierarchy.refactor"/>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package ai.openidea.implementations;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.internal.corext.util.MethodOverrideTester;
|
||||
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.lsp4j.Location;
|
||||
|
||||
/**
|
||||
* Implementations of the type or method at a position, looked up in the workspace's source
|
||||
* folders only. The standard request builds the type hierarchy over every dependency jar too,
|
||||
* which takes long in a project with many dependencies; the client falls back to it when the
|
||||
* sources have none.
|
||||
*/
|
||||
public final class ImplementationsCommand 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();
|
||||
List<Location> result = new ArrayList<>();
|
||||
ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
|
||||
if (root == null) return result;
|
||||
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
|
||||
IJavaElement element = JDTUtils.findElementAtSelection(root, line, column, preferences, monitor);
|
||||
IMethod method = null;
|
||||
IType type;
|
||||
if (element instanceof IMethod m) {
|
||||
int flags = m.getFlags();
|
||||
if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return result;
|
||||
method = m;
|
||||
type = m.getDeclaringType();
|
||||
} else if (element instanceof IType t) {
|
||||
type = t;
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
if (type == null || type.getJavaProject() == null) return result;
|
||||
IRegion sources = JavaCore.newRegion();
|
||||
for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) {
|
||||
for (IPackageFragmentRoot fragmentRoot : project.getPackageFragmentRoots()) {
|
||||
if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE) sources.add(fragmentRoot);
|
||||
}
|
||||
}
|
||||
ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor);
|
||||
for (IType subtype : hierarchy.getAllSubtypes(type)) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
IMember found;
|
||||
if (method == null) {
|
||||
found = subtype.isInterface() ? null : subtype;
|
||||
} else {
|
||||
// Seen from the subtype, so its type arguments substitute the method's type variables.
|
||||
IMethod overriding = new MethodOverrideTester(subtype, hierarchy).findOverridingMethodInType(subtype, method);
|
||||
found = overriding == null || Flags.isAbstract(overriding.getFlags()) ? null : overriding;
|
||||
}
|
||||
Location location = found == null ? null : JDTUtils.toLocation(found);
|
||||
if (location != null) result.add(location);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Go to Implementation in project sources against a real JDT LS. Argument: jdtls home."""
|
||||
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-implementations-'))
|
||||
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 = {
|
||||
'Repo.java': '''package zoo;
|
||||
|
||||
public interface Repo<T> {
|
||||
void save(T item);
|
||||
|
||||
default String name() {
|
||||
return "repo";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'SubRepo.java': '''package zoo;
|
||||
|
||||
public interface SubRepo extends Repo<String> {
|
||||
}
|
||||
''',
|
||||
'Base.java': '''package zoo;
|
||||
|
||||
public abstract class Base implements Repo<Integer> {
|
||||
public abstract void save(Integer item);
|
||||
}
|
||||
''',
|
||||
'Numbers.java': '''package zoo;
|
||||
|
||||
public class Numbers extends Base {
|
||||
public void save(Integer item) {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Words.java': '''package zoo;
|
||||
|
||||
public class Words implements SubRepo {
|
||||
public void save(String item) {
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return "words";
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Task.java': '''package zoo;
|
||||
|
||||
public class Task implements Runnable {
|
||||
public void run() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
'Use.java': '''package zoo;
|
||||
|
||||
public class Use {
|
||||
void use(Repo<String> repo, Runnable task) {
|
||||
repo.save("x");
|
||||
task.run();
|
||||
}
|
||||
|
||||
void alone() {
|
||||
}
|
||||
}
|
||||
''',
|
||||
}
|
||||
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 implementations(file, needle):
|
||||
line, col = position(file, needle)
|
||||
found = command('openidea.java.implementations', uri(file), line, col)
|
||||
names = []
|
||||
for loc in found:
|
||||
name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name
|
||||
names.append(f"{name}:{loc['range']['start']['line']}")
|
||||
return sorted(names)
|
||||
|
||||
|
||||
try:
|
||||
init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'settings': {'java': {'import': {'gradle': {'enabled': False}, 'maven': {'enabled': False}}}}}})
|
||||
assert 'openidea.java.implementations' in init['capabilities']['executeCommandProvider']['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)
|
||||
cases = {
|
||||
# Classes, abstract ones too; not the sub-interface.
|
||||
('Repo.java', 'Repo<T>'): ['Base.java:2', 'Numbers.java:2', 'Words.java:2'],
|
||||
# Through the type arguments; not the abstract re-declaration.
|
||||
('Repo.java', 'save(T'): ['Numbers.java:3', 'Words.java:3'],
|
||||
('Use.java', 'save("x")'): ['Numbers.java:3', 'Words.java:3'],
|
||||
('Repo.java', 'name()'): ['Words.java:6'],
|
||||
# A library type's implementations in the sources.
|
||||
('Use.java', 'Runnable task'): ['Task.java:2'],
|
||||
('Use.java', 'run();'): ['Task.java:3'],
|
||||
('Use.java', 'alone()'): [],
|
||||
}
|
||||
failures = []
|
||||
for (file, needle), expected in cases.items():
|
||||
actual = implementations(file, needle)
|
||||
print(f'{file} {needle!r}: {actual}')
|
||||
if actual != expected:
|
||||
failures.append((file, needle, expected, actual))
|
||||
assert not failures, failures
|
||||
print('ok')
|
||||
finally:
|
||||
proc.kill()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
Reference in New Issue
Block a user