дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02: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.
Цей коміт міститься в:
1 джерело
0a47a685d9
коміт
6026374973
9 files changed
+343
-3
No files matched your search
@@ -315,6 +315,8 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
|
||||
|
||||
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
|
||||
match kind {
|
||||
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
|
||||
@@ -1259,6 +1261,40 @@ impl LspManager {
|
||||
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
|
||||
}
|
||||
|
||||
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`).
|
||||
/// Its standard request builds the type hierarchy over every dependency jar too, which takes
|
||||
/// long in a project with many dependencies; empty (or no extension) falls back to it.
|
||||
async fn java_source_implementations(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
t: &Target,
|
||||
pos: Pos,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> Vec<Location> {
|
||||
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
|
||||
return Vec::new();
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
let arguments =
|
||||
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
|
||||
let result: IdeResult<Vec<lsp::Location>> = self
|
||||
.call(
|
||||
t,
|
||||
"workspace/executeCommand",
|
||||
json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }),
|
||||
cancel,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(locs) => convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(locs)),
|
||||
Err(e) => {
|
||||
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn goto_in(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
@@ -1269,6 +1305,14 @@ impl LspManager {
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
if method == "textDocument/implementation" {
|
||||
let locs = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
if !locs.is_empty() {
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
|
||||
return Ok(locs);
|
||||
}
|
||||
}
|
||||
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
|
||||
@@ -1804,6 +1804,37 @@ async fn implementations_per_position() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_implementations_come_from_sources_first() {
|
||||
let caps = json!({
|
||||
"textDocumentSync": 1,
|
||||
"implementationProvider": true,
|
||||
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
|
||||
});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let uri = uri_of(&path);
|
||||
let at = move |line: u32| json!([{ "uri": uri, "range": {"start":{"line":line,"character":6},"end":{"line":line,"character":7}} }]);
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// The sources have an implementation of line 0 only.
|
||||
"workspace/executeCommand" => Some(if params["arguments"][1] == 0 { at(1) } else { json!([]) }),
|
||||
"textDocument/implementation" => Some(at(3)),
|
||||
_ => None,
|
||||
}));
|
||||
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
|
||||
assert_eq!(found.iter().map(|l| l.range.start.line).collect::<Vec<_>>(), vec![1]);
|
||||
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
|
||||
let asked = s.requested("workspace/executeCommand");
|
||||
assert_eq!(asked[0]["command"], "openidea.java.implementations");
|
||||
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert_eq!(found.iter().map(|l| l.range.start.line).collect::<Vec<_>>(), vec![3]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navigation_rename_format_and_decorations() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
|
||||
@@ -1169,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.3.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.4.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -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
|
||||
|
||||
Бінарний файл не відображається.
@@ -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
Заблокувати користувача