diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 556dd1ed..b49c7f1d 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -315,6 +315,8 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec) -> Vec (&'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 { + 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> = 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> { 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>( t, diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 8642c576..fb084bfa 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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![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![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())]); diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 6de7d667..343ff9fc 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -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 { - 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); diff --git a/java/java-usages/MANIFEST.MF b/java/java-usages/MANIFEST.MF index 252534d5..6d2f7818 100644 --- a/java/java-usages/MANIFEST.MF +++ b/java/java-usages/MANIFEST.MF @@ -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, diff --git a/java/java-usages/README.md b/java/java-usages/README.md index f5d7b242..4db3a3ef 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -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 diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 2493c96f..5702ed6c 100644 Binary files a/java/java-usages/openidea-java-usages.jar and b/java/java-usages/openidea-java-usages.jar differ diff --git a/java/java-usages/plugin.xml b/java/java-usages/plugin.xml index d6c345e0..d87e2eed 100644 --- a/java/java-usages/plugin.xml +++ b/java/java-usages/plugin.xml @@ -7,6 +7,9 @@ + + + diff --git a/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java new file mode 100644 index 00000000..9d024d18 --- /dev/null +++ b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java @@ -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 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 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; + } +} diff --git a/java/java-usages/tests/implementations.py b/java/java-usages/tests/implementations.py new file mode 100644 index 00000000..017bd9a5 --- /dev/null +++ b/java/java-usages/tests/implementations.py @@ -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('zooorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature') +(project / '.classpath').write_text('') +sources = { + 'Repo.java': '''package zoo; + +public interface Repo { + void save(T item); + + default String name() { + return "repo"; + } +} +''', + 'SubRepo.java': '''package zoo; + +public interface SubRepo extends Repo { +} +''', + 'Base.java': '''package zoo; + +public abstract class Base implements Repo { + 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 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'): ['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)