From 401b4a9441ea6dec726da6918021bef0f5cf96f9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:16:13 +0800 Subject: [PATCH] fix(java): list dependencies' implementations after the project's in Go to Implementation (#1644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go to Implementation on a type or method declared in a dependency listed only the project's implementations once the project had one. openidea.java.implementations (java-usages 1.5.0) now answers {locations, complete}, complete when the type is declared in the sources, and also searches the Kotlin bridge's classes. When not complete, the chooser opens with the project's implementations (searching…) through the new lsp_goto_implementation channel and fills in the dependencies' when the standard request ends; closing it cancels the search. --- crates/ide-host/src/ops/lsp.rs | 26 +++++ crates/ide-lsp/src/lib.rs | 86 +++++++++++--- crates/ide-lsp/tests/mock_server.rs | 50 +++++++-- crates/ide-sdk/src/java.rs | 2 +- java/java-usages/MANIFEST.MF | 2 +- java/java-usages/README.md | 8 +- java/java-usages/openidea-java-usages.jar | Bin 106906 -> 108367 bytes .../ImplementationsCommand.java | 47 ++++++-- java/java-usages/tests/implementations.py | 32 +++--- src-tauri/src/commands/lsp.rs | 17 +++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 6 + src/editor/lsp/navigation.ts | 106 +++++++++++++++++- src/editor/lsp/navigationMouse.test.ts | 11 +- src/editor/lsp/navigationPending.test.ts | 105 ++++++++++++++++- src/platform/locales/text/zh.json | 3 + 16 files changed, 441 insertions(+), 61 deletions(-) diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 2e2402f3..a4eb7957 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -257,6 +257,28 @@ pub async fn lsp_implementations( result } +/// Go to Implementation of the symbol at `pos`. Implementations found before a slower +/// search for the rest (Java: the project's, before the dependencies') arrive on +/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`. +pub async fn lsp_goto_implementation( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + pos: Pos, + on_partial: super::Channel>, +) -> IdeResult> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp + .goto_implementation(doc, pos, cancel, &|locs| { + let _ = on_partial.send(locs); + }) + .await; + host.end_request(request); + result +} + /// Contents of a read-only language-server document, including JDT library /// classes and disk class files. Cached per project. pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult { @@ -746,6 +768,10 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_implementations", |c| async move { lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await }); + d.add("lsp_goto_implementation", |c| async move { + let on_partial = c.sender("onPartial")?; + lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await + }); d.add( "lsp_resolve_class_file", |c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await }, diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index b49c7f1d..a8daa057 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -317,6 +317,25 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec) -> Vec, + complete: bool, +} + +/// Receives the implementations found so far, before a slower search for the rest. +type Partial<'a> = Option<&'a (dyn Fn(Vec) + Send + Sync)>; + +/// `first`, then those of `more` not in it (same file and start). +fn merge_locations(first: Vec, more: Vec) -> Vec { + let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect(); + let mut out = first; + out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start)))); + out +} + fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) { match kind { GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"), @@ -1231,7 +1250,22 @@ impl LspManager { } let (method, cap, feature) = goto_method(kind); let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; - self.goto_in(&snap, &targets, method, pos, None).await + self.goto_in(&snap, &targets, method, pos, None, None).await + } + + /// Go to Implementation, with the implementations found first passed to `partial` while + /// the search goes on: jdtls answers from the project's sources quickly, then searches every + /// dependency jar for a type declared in one. Returns them all, the first ones first. + pub async fn goto_implementation( + &self, + doc: DocId, + pos: Pos, + cancel: CancellationToken, + partial: &(dyn Fn(Vec) + Send + Sync), + ) -> IdeResult> { + let (method, cap, feature) = goto_method(GotoKind::Implementation); + let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; + self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await } /// Implementations of the symbols at each of `positions` (gutter line @@ -1248,7 +1282,7 @@ impl LspManager { let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?; let requests = positions.iter().copied().map(|pos| { let (snap, targets, cancel) = (&snap, &targets, &cancel); - async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await } + async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await } }); let results: Vec>> = futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await; @@ -1261,23 +1295,23 @@ 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. + /// jdtls: the implementations in the project's sources (`openidea.java.implementations`), + /// and whether they are all there are. Its standard request builds the type hierarchy over + /// every dependency jar too, which takes long in a project with many dependencies. async fn java_source_implementations( &self, snap: &DocSnap, t: &Target, pos: Pos, cancel: Option<&CancellationToken>, - ) -> Vec { + ) -> (Vec, bool) { if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) { - return Vec::new(); + return (Vec::new(), false); } let params = snap.pos_params(t, pos); let arguments = json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]); - let result: IdeResult> = self + let result: IdeResult = self .call( t, "workspace/executeCommand", @@ -1287,10 +1321,16 @@ impl LspManager { ) .await; match result { - Ok(locs) => convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(locs)), + Ok(found) => { + let locs = + convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations)); + let locs = library_sources(snap, pos, locs).await; + self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); + (locs, found.complete) + } Err(e) => { tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message); - Vec::new() + (Vec::new(), false) } } } @@ -1302,16 +1342,23 @@ impl LspManager { method: &str, pos: Pos, cancel: Option<&CancellationToken>, + partial: Partial<'_>, ) -> IdeResult> { let mut out = Outcome::new(); for t in targets.iter().filter(|t| snap.asks(t, pos)) { + // Found in the sources before the standard request searches the dependencies too. + let mut first = Vec::new(); 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())); + let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await; + if complete && !locs.is_empty() { return Ok(locs); } + if let Some(partial) = partial + && !locs.is_empty() + { + partial(locs.clone()); + first = locs; + } } let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || { self.call::<_, Option>( @@ -1328,12 +1375,19 @@ impl LspManager { let locs = convert::goto_response(&self.conv(t.enc()), r); let locs = library_sources(snap, pos, locs).await; self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); + let locs = merge_locations(first, locs); if !locs.is_empty() { return Ok(locs); } out.value = Some(Vec::new()); } + Ok(None) if !first.is_empty() => return Ok(first), Ok(None) => out.value = Some(Vec::new()), + // Those shown stay valid when the rest can't be found. + Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => { + tracing::debug!("go to implementation in the dependencies: {}", e.message); + return Ok(first); + } Err(e) => out.fail(e), } } @@ -1517,7 +1571,9 @@ impl LspManager { return Vec::new(); } let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await; - self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default() + self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None) + .await + .unwrap_or_default() }; let (results, declarations) = futures::join!(join_all(requests), declarations); let mut out = Outcome::new(); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index fb084bfa..68adee62 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1816,23 +1816,57 @@ async fn java_implementations_come_from_sources_first() { 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}} }]); + let at = move |lines: &[u32]| { + lines + .iter() + .map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} })) + .collect::() + }; *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)), + // Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3. + "workspace/executeCommand" => Some(match params["arguments"][1].as_u64() { + Some(0) => json!({ "locations": at(&[1]), "complete": true }), + Some(2) => json!({ "locations": at(&[1]), "complete": false }), + _ => json!({ "locations": [], "complete": true }), + }), + "textDocument/implementation" => Some(at(&[3, 1])), _ => None, })); + let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::>(); 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_eq!(lines(&found), vec![1]); + let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap(); + assert_eq!(lines(&markers[0]), 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]); + + // A dependency's type: those in the sources first, then the dependencies' too. + let partial = Mutex::new(Vec::new()); + let found = h + .mgr + .goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs))) + .await + .unwrap(); + assert_eq!(*partial.lock(), vec![vec![1]]); + assert_eq!(lines(&found), vec![1, 3]); assert_eq!(s.requested("textDocument/implementation").len(), 1); + // Without a receiver for the first ones (gutter markers), just all of them. + let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap(); + assert_eq!(lines(&found), vec![3, 1]); + assert_eq!(s.requested("textDocument/implementation").len(), 2); + + // None in the sources: the standard request, with the dependencies. + let partial = Mutex::new(Vec::new()); + let found = h + .mgr + .goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs))) + .await + .unwrap(); + assert!(partial.lock().is_empty()); + assert_eq!(lines(&found), vec![3, 1]); + assert_eq!(s.requested("textDocument/implementation").len(), 3); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 343ff9fc..557f6703 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.4.0.jar"); + let extension = data_dir.join("java-usages-1.5.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 6d2f7818..564dd5ba 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.4.0 +Bundle-Version: 1.5.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 4db3a3ef..ed5aa0d8 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.4.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.5.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. @@ -78,6 +78,11 @@ Version 1.3.0 adds the class-hierarchy refactoring commands; its reproducible SH 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`. +Version 1.5.0 answers `{locations, complete}`: `complete` when the type is declared in the sources +(no dependency implements it), otherwise the app also runs the standard request and adds the +dependencies' implementations after the first ones. The search also covers the project's Kotlin +classes attached by the Kotlin bridge; its reproducible SHA-256 is +`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`. ```sh export PATH=/path/to/jdk25/bin:$PATH @@ -107,6 +112,7 @@ python3 java/java-usages/tests/verify.py /path/to/jdtls /path/to/lombok.jar python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write.json OPENIDEA_USAGES_INDEX_STRESS=1 python3 java/java-usages/tests/read-write.py /path/to/jdtls /path/to/lombok.jar > /tmp/read-write-indexed.json python3 java/java-usages/tests/hierarchy.py /path/to/jdtls +python3 java/java-usages/tests/implementations.py /path/to/jdtls ``` The harnesses select the JDT LS configuration for the current platform. The first diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 5702ed6c70c874b9303cf822563a1ed85bfa96b1..8208527be9d600bebbc6279788879c9827a8b5e4 100644 GIT binary patch delta 3433 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 bY~tB6UaRS$(u{|;iPb$c%%0OD8|}XV-TVck delta 2457 zcmbVOYj9J?75v^a97hCq` z0ekb*u#eWz2kH|a9k)4`tByG3+wJhG<8hK^!L1|YH_zUBtx!iuAV|o0IyM-qJCKO? z)UAsTBs7E}%_X^|p#p+0W9y0acGqo8^v8R<1&UO&PI_S>N;H(BjNpp*CN{--4s;Rh z3o2U$=9;e@32B%EuLl)aATZzj^T)OJN>rJ#Lv8MA)C$xPmYUa3EX~qThkB*;&<>A< zUZ`Oa8rADT`iP>cFGOWUO<1B~X(m&5S7LQftb4$VCM?si94pk@Aqu;j(IW6LA!1%~ z>l-o<4UgbatGI!UiC7}uNpLJ^t=#58Gal2h+OoOh0~`9|gRw-HrCqC`)zXUi!0LE! ztjEgHrePh{t6k4gqg~)}^XzX+L_0b(d>-b*zc+Y)PhzvcCPI;wS$!HNk9`Zas=Hxo zaDD+h1h%U>Np<#}cuK`2t#HQBDX`nT{!*m_d(fqRC26_qNpx%YBH~Jq(02Ei@QgqY zp~Q@ZBA)g8_H^}V=*2#DHo^h+V?dx^-HuR&JAng)5VKvSV z1-`CMv{CrfogBt}1K(7|MbzPZ8OH^_WgdR9TG#LjRuDY#fk$J#on1X$djw9JC5_R! z9-M&Ji*}sW@T!IJ#s^w^6J7m#W1U?B%IbiQ+oh_mmN-& zig^$9ce1mWRc`WN7_Vvg4$i7Ki)pB&8EFHEH{g`wN0WHSa7&k-r8}j&W)cmChYYXuTu0Lg)=JMcG#S}4 zJB7uBXpS{82%{*_b0)EJ6nU0hH43jK*NkLB%4`n6z|Rp3Z5)LhEQ$6J_^oK|Mvly} z)K+q-eTZzSrLci8Q_d4c?j*LQu!}H>y(v6R*lOfmjRx#d|2Q6#dAfh0;gh};`YcQ6 zYBXq%>f%p<~CrACH`}M$B`u%%(CBte` zIP8EG)l$A-q;Vd_Azx@H6Hm!U()mIKfPw`l(&BA*HXOc!x20w&P1~WuX;`JGiprK3sM#kMG^+}wYMrKQ4VK3US z`DlRjfSxy&KFEw^y1?p`VU+V2=B|+_Km=7hEt{KaxVHX zkMCGHms){yn2&d{fC;X|6uVwUH9kTOevMk(MI8lDPZccdQA-9E(Rwse98GkPyC8`r zbP`MHG?vj@SWZ8~O1gz+y3LqR(Sq-CpKeDa%~iMsNFXAhCk0;S8SzpV_#TkPfa4b{oyH>HG|HUtPjAK}( zvTLA)=Z-YD0{^&w=EV2?t#g5$V>G4tVG4e>5Zf@s#m~eLxB|FV2~4K>W&3}&Z#|~( zXYRt(#Gfi@$+HgY-_FECWAu!D_Wzu7=V{Pk%p<6V#Z;n}U7!X>q;`5)u3o)B_4bvE zN%>Y#x%$Hes&Wi&o;HTnwWlao_3a>!T6d8Rmv_!G=xY2T)jKZlo~3iCzp~DDSE-tD MDs=8X!<~!218LLf9RL6T diff --git a/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java index 9d024d18..1dbedf11 100644 --- a/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java +++ b/java/java-usages/src/ai/openidea/implementations/ImplementationsCommand.java @@ -2,6 +2,7 @@ package ai.openidea.implementations; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.OperationCanceledException; @@ -13,39 +14,52 @@ 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. + * Implementations of the type or method at a position, looked up in the workspace's own code + * only (source folders and bridged Kotlin classes): {@code {locations, complete}}. The standard + * request builds the type hierarchy over every dependency jar too, which takes long in a project + * with many dependencies. {@code complete} when the type is declared in the sources, which no + * dependency extends; otherwise the client also asks the standard request for the dependencies' + * implementations. */ public final class ImplementationsCommand implements IDelegateCommandHandler { + /** Classpath attribute of the Kotlin bridge's library entries (`java/kotlin-bridge`). */ + private static final String KOTLIN_OUTPUT = "ai.openidea.kotlin"; + @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + List locations = new ArrayList<>(); + boolean complete = find(arguments, locations, monitor); + return Map.of("locations", locations, "complete", complete); + } + + /** Adds the implementations to {@code result}; whether they are all there are. */ + private static boolean find(List arguments, List result, 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; + if (root == null) return false; 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; + if (m.isConstructor() || Flags.isStatic(flags) || Flags.isPrivate(flags) || Flags.isFinal(flags)) return false; method = m; type = m.getDeclaringType(); } else if (element instanceof IType t) { type = t; } else { - return result; + return false; } - if (type == null || type.getJavaProject() == null) return result; + if (type == null || type.getJavaProject() == null) return false; 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); + if (fragmentRoot.getKind() == IPackageFragmentRoot.K_SOURCE || isKotlinOutput(fragmentRoot)) { + sources.add(fragmentRoot); + } } } ITypeHierarchy hierarchy = type.getJavaProject().newTypeHierarchy(type, sources, monitor); @@ -62,6 +76,17 @@ public final class ImplementationsCommand implements IDelegateCommandHandler { Location location = found == null ? null : JDTUtils.toLocation(found); if (location != null) result.add(location); } - return result; + return !type.isBinary(); + } + + /** The project's compiled Kotlin classes, attached as a library by the Kotlin bridge. */ + private static boolean isKotlinOutput(IPackageFragmentRoot root) throws JavaModelException { + if (root.getKind() != IPackageFragmentRoot.K_BINARY) return false; + IClasspathEntry entry = root.getRawClasspathEntry(); + if (entry == null) return false; + for (IClasspathAttribute attribute : entry.getExtraAttributes()) { + if (KOTLIN_OUTPUT.equals(attribute.getName()) && "true".equals(attribute.getValue())) return true; + } + return false; } } diff --git a/java/java-usages/tests/implementations.py b/java/java-usages/tests/implementations.py index 017bd9a5..ed39b519 100644 --- a/java/java-usages/tests/implementations.py +++ b/java/java-usages/tests/implementations.py @@ -24,7 +24,8 @@ config_source = next(home / name / 'config.ini' for name in config_names if (hom 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('') +# `kotlin`: the project's compiled Kotlin classes, as the Kotlin bridge attaches them. +(project / '.classpath').write_text('') sources = { 'Repo.java': '''package zoo; @@ -88,6 +89,10 @@ public class Use { for name, text in sources.items(): (project / 'src/zoo' / name).write_text(text) uri = lambda name: (project / 'src/zoo' / name).as_uri() +# Stands in for a Kotlin class of the project implementing the Java interface. +(work / 'kotlin-src/zoo').mkdir(parents=True) +(work / 'kotlin-src/zoo/Longs.java').write_text('package zoo;\n\npublic class Longs implements Repo {\n public void save(Long item) {\n }\n}\n') +subprocess.run(['javac', '-implicit:none', '-sourcepath', str(project / 'src'), '-d', str(project / 'kotlin'), str(work / 'kotlin-src/zoo/Longs.java')], check=True) 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) @@ -154,30 +159,31 @@ def implementations(file, needle): line, col = position(file, needle) found = command('openidea.java.implementations', uri(file), line, col) names = [] - for loc in found: + for loc in found['locations']: name = pathlib.Path(urllib.parse.unquote(urllib.parse.urlparse(loc['uri']).path)).name names.append(f"{name}:{loc['range']['start']['line']}") - return sorted(names) + return sorted(names), found['complete'] 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}}}}}}) + init = request('initialize', {'processId': None, 'rootUri': project.as_uri(), 'workspaceFolders': [{'uri': project.as_uri(), 'name': 'zoo'}], 'capabilities': {}, 'initializationOptions': {'bundles': [str(bundle)], 'extendedClientCapabilities': {'classFileContentsSupport': True}, '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) + # (implementations in the sources, whether they are all there are) cases = { - # Classes, abstract ones too; not the sub-interface. - ('Repo.java', 'Repo'): ['Base.java:2', 'Numbers.java:2', 'Words.java:2'], + # Classes, abstract ones too, and those of the bridged Kotlin output; not the sub-interface. + ('Repo.java', 'Repo'): (['Base.java:2', 'Longs.java:0', 'Numbers.java:2', 'Words.java:2'], True), # 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()'): [], + ('Repo.java', 'save(T'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True), + ('Use.java', 'save("x")'): (['Longs.java:0', 'Numbers.java:3', 'Words.java:3'], True), + ('Repo.java', 'name()'): (['Words.java:6'], True), + # A library type's implementations in the sources; the library has more. + ('Use.java', 'Runnable task'): (['Task.java:2'], False), + ('Use.java', 'run();'): (['Task.java:3'], False), + ('Use.java', 'alone()'): ([], True), } failures = [] for (file, needle), expected in cases.items(): diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 1db210fa..98dc7e92 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -80,6 +80,23 @@ pub async fn lsp_goto( ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await } +/// Go to Implementation of the symbol at `pos`. Implementations found before a +/// slower search for the rest arrive on `on_partial`; the result has them all. +/// Cancel with `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn lsp_goto_implementation( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + pos: Pos, + on_partial: tauri::ipc::Channel>, +) -> IdeResult> { + let on_partial = super::sender(on_partial); + ide_host::ops::lsp::lsp_goto_implementation(&state.host, window.label(), request, doc, pos, on_partial).await +} + /// Implementations of the symbols at `positions`, one list per position (the /// editor's "is implemented / overridden" gutter markers). Cancel with /// `cancel_request(request)`. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d5645df7..398f73d2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -219,6 +219,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_signature_help, lsp::lsp_goto, lsp::lsp_implementations, + lsp::lsp_goto_implementation, lsp::lsp_virtual_document, lsp::lsp_resolve_class_file, lsp::lsp_references, diff --git a/src/bindings.ts b/src/bindings.ts index 28ff07b2..5fbff64b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -409,6 +409,12 @@ export const commands = { * `cancel_request(request)`. */ lspImplementations: (request: RequestId, doc: DocId, positions: Pos[]) => typedError(__TAURI_INVOKE("lsp_implementations", { request, doc, positions })), + /** + * Go to Implementation of the symbol at `pos`. Implementations found before a + * slower search for the rest arrive on `on_partial`; the result has them all. + * Cancel with `cancel_request(request)`. + */ + lspGotoImplementation: (request: RequestId, doc: DocId, pos: Pos, onPartial: Channel) => typedError(__TAURI_INVOKE("lsp_goto_implementation", { request, doc, pos, onPartial })), /** * Contents of a read-only language-server document, including JDT library * classes and disk class files. Cached per project. diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 16ec470c..187b79c7 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -15,13 +15,17 @@ import { type Tooltip, type ViewUpdate, } from "@codemirror/view"; +import { Channel } from "@tauri-apps/api/core"; import { commands, isCancelled, + nextRequestId, + type DocId, type GotoKind, type Highlight, type Location, type Pos, + type RequestId, type WebReferenceKind, } from "@/platform/ipc"; import { @@ -48,7 +52,7 @@ import { } from "./syntaxFallback"; import { showHint, showPendingHint } from "./hint"; import { outsideProjectReason } from "./outsideProject"; -import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore"; +import { caretAnchor, openPopup, updatePopup, type ListPopupItem } from "./popupStore"; import { readManyFileLines, targetLabel } from "./files"; import { documentLinks, @@ -134,9 +138,89 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise /** "Choose Implementation of foo (3 found)". */ export function chooserTitle(kind: GotoKind, name: string | null, count: number, fromSyntax = false): string { + if (kind === "implementation" && !fromSyntax) + return name ? tx`Choose Implementation of ${name} (${count} found)` : tx`Choose Implementation (${count} found)`; return `${TITLES[kind]}${name ? ` of ${name}` : ""} (${count} found${fromSyntax ? ", based on syntax" : ""})`; } +/** The chooser's title while the server is still searching for more. */ +function searchingTitle(name: string | null, count: number): string { + return name + ? tx`Choose Implementation of ${name} (${count} found, searching…)` + : tx`Choose Implementation (${count} found, searching…)`; +} + +function nameAt(view: EditorView, offset: number): string | null { + const word = view.state.wordAt(offset); + return word ? view.state.sliceDoc(word.from, word.to) : null; +} + +/** + * Go to Implementation whose chooser opens with the implementations the server finds first + * (Java: the project's) while it searches on (the dependencies'), and fills in when it ends. + * Closing the chooser cancels the search. + */ +function streamedImplementations(view: EditorView, offset: number, onOpen: () => void) { + const name = nameAt(view, offset); + let current: RequestId | null = null; + let finished = false; + let opened = false; + let closed = false; + let first: DeclarationTarget[] = []; + let showing: Promise = Promise.resolve(); + return { + request(doc: DocId, pos: Pos): Promise { + const request = (current = nextRequestId()); + finished = false; + const channel = new Channel(); + channel.onmessage = (locs) => { + if (finished || opened || current !== request || !locs.length) return; + opened = true; + onOpen(); + first = dedupeLocations(locs); + showing = locationItems(first).then((items) => { + // All of them arrived meanwhile: shown as usual. + if (finished) { + opened = false; + return; + } + openPopup({ + kind: "list", + anchor: caretAnchor(view, offset), + title: searchingTitle(name, first.length), + items, + pin: pinToFind(name ?? "", first), + onClose: () => { + closed = true; + if (!finished) void commands.cancelRequest(request).catch(() => {}); + }, + }); + }); + }; + return lspRequest(() => commands.lspGotoImplementation(request, doc, pos, channel)).finally(() => { + finished = true; + }); + }, + /** Whether the chooser opened with the first ones (and so the search's result goes there). */ + async opened(): Promise { + await showing; + return opened; + }, + /** Fills the opened chooser with all of them, after those it shows. */ + async complete(locs: readonly DeclarationTarget[]) { + if (closed) return; + const all = dedupeLocations([...first, ...locs]); + const items = await locationItems(all); + if (closed) return; + updatePopup("list", { + title: chooserTitle("implementation", name, all.length), + items, + pin: pinToFind(name ?? "", all), + }); + }, + }; +} + /** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */ export function pinToFind(symbol: string, locations: readonly Location[]) { return { @@ -175,14 +259,25 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) let fromSyntax = false; const declaration = kind === "definition" || kind === "declaration"; let serverDown = false; + let streamed: ReturnType | null = null; try { const done = showPendingHint(view, tx`Finding navigation target…`); + const stream = kind === "implementation" ? streamedImplementations(view, offset, done) : null; + streamed = stream; + const request = stream + ? () => stream.request(doc.doc, pos) + : () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind)); try { - locs = dedupeLocations(await gotoAfterEdits(view, () => lspRequest(() => commands.lspGoto(doc.doc, pos, kind)))); + locs = dedupeLocations(await gotoAfterEdits(view, request)); } finally { done(); } } catch (e) { + // The chooser shows the first ones: closing it cancelled the search, or the rest failed. + if (streamed && (await streamed.opened())) { + await streamed.complete([]); + return; + } if (isCancelled(e) || !declaration) { reportLspError(view, e, true); return; @@ -199,6 +294,10 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) locs = fb; fromSyntax = true; } + if (streamed && (await streamed.opened())) { + await streamed.complete(locs); + return; + } if (!locs.length) { // The server has no definition here: a link (`href`, `#include`, Markdown) or a file path. if ( @@ -234,8 +333,7 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) }, 150); return; } - const word = view.state.wordAt(offset); - const name = word ? view.state.sliceDoc(word.from, word.to) : null; + const name = nameAt(view, offset); openPopup({ kind: "list", anchor: caretAnchor(view, offset), diff --git a/src/editor/lsp/navigationMouse.test.ts b/src/editor/lsp/navigationMouse.test.ts index b44ec67d..ef49c770 100644 --- a/src/editor/lsp/navigationMouse.test.ts +++ b/src/editor/lsp/navigationMouse.test.ts @@ -10,8 +10,17 @@ import { registerAction } from "@/platform/actions"; const lspGoto = vi.hoisted(() => vi.fn()); vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); - return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } }; + // Go to Implementation streams (`lspGotoImplementation`); here it answers all at once. + const lspGotoImplementation: typeof commands.lspGotoImplementation = (_request, doc, pos) => + lspGoto(doc, pos, "implementation"); + return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto, lspGotoImplementation } }; }); +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + }, +})); const { gotoTargets, navigationExtension } = await import("./navigation"); const { lspDoc } = await import("./lsp"); diff --git a/src/editor/lsp/navigationPending.test.ts b/src/editor/lsp/navigationPending.test.ts index 50589aca..b23d90bd 100644 --- a/src/editor/lsp/navigationPending.test.ts +++ b/src/editor/lsp/navigationPending.test.ts @@ -1,16 +1,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import type { commands } from "@/platform/ipc"; +import type { commands, Location } from "@/platform/ipc"; -const lspGoto = vi.hoisted(() => vi.fn()); +const { lspGoto, lspGotoImplementation, cancelRequest } = vi.hoisted(() => ({ + lspGoto: vi.fn(), + lspGotoImplementation: vi.fn(), + cancelRequest: vi.fn(async () => {}), +})); vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); - return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } }; + const fsReadText: typeof commands.fsReadText = async () => ({ + status: "error", + error: { kind: "notFound", message: "not found" }, + }); + return { + ...real, + isTauri: () => true, + commands: { ...real.commands, lspGoto, lspGotoImplementation, cancelRequest, fsReadText }, + }; }); +vi.mock("@tauri-apps/api/core", async (orig) => ({ + ...(await orig()), + Channel: class { + onmessage = () => {}; + }, +})); const { gotoTargets } = await import("./navigation"); const { lspDoc } = await import("./lsp"); +const { useLspPopup, closePopup } = await import("./popupStore"); const { provideEditorService } = await import("@/workbench/contributions"); const openFile = vi.fn(async () => {}); provideEditorService({ openFile } as never); @@ -50,13 +69,87 @@ describe("slow navigation feedback", () => { }); it("replaces progress with the server error", async () => { - let resolve!: (result: Awaited>) => void; - lspGoto.mockImplementationOnce(() => new Promise((r) => (resolve = r))); + const search = slowImplementations(); const pending = gotoTargets(view, "implementation"); await vi.advanceTimersByTimeAsync(250); - resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } }); + search.resolve({ status: "error", error: { kind: "protocol", message: "Server timed out" } }); await pending; expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("Server timed out"); expect(openFile).not.toHaveBeenCalled(); }); }); + +const at = (path: string, line: number): Location => ({ + path, + range: { start: { line, col: 0 }, end: { line, col: 1 } }, + uri: null, +}); + +/** A Go to Implementation request answered when the test says: the first ones, then all. */ +function slowImplementations() { + let channel!: { onmessage: (locs: Location[]) => void }; + let resolve!: (result: Awaited>) => void; + lspGotoImplementation.mockImplementationOnce((_request, _doc, _pos, onPartial) => { + channel = onPartial as unknown as typeof channel; + return new Promise((r) => (resolve = r)); + }); + return { + first: (locs: Location[]) => channel.onmessage(locs), + resolve: (r: Parameters[0]) => resolve(r), + }; +} + +const listPopup = () => { + const popup = useLspPopup.getState().popup; + return popup?.kind === "list" ? popup : null; +}; + +describe("Go to Implementation while the search goes on", () => { + afterEach(() => { + closePopup(); + cancelRequest.mockClear(); + lspGotoImplementation.mockReset(); + }); + + it("opens the chooser with the implementations found first, then adds the rest", async () => { + const search = slowImplementations(); + const pending = gotoTargets(view, "implementation"); + await vi.advanceTimersByTimeAsync(250); + search.first([at("/p/Own.java", 3)]); + await vi.waitFor(() => expect(listPopup()?.title).toBe("Choose Implementation of run (1 found, searching…)")); + expect(listPopup()!.items.map((i) => i.label)).toEqual(["Own.java"]); + expect(document.querySelector(".cm-lsp-hint")).toBeNull(); + + search.resolve({ status: "ok", data: [at("/p/Library.java", 8), at("/p/Own.java", 3)] }); + await pending; + expect(listPopup()?.title).toBe("Choose Implementation of run (2 found)"); + expect(listPopup()!.items.map((i) => i.label)).toEqual(["Own.java", "Library.java"]); + expect(openFile).not.toHaveBeenCalled(); + expect(cancelRequest).not.toHaveBeenCalled(); + }); + + it("cancels the search when the chooser closes", async () => { + const search = slowImplementations(); + const pending = gotoTargets(view, "implementation"); + await vi.waitFor(() => expect(lspGotoImplementation).toHaveBeenCalled()); + search.first([at("/p/Own.java", 3)]); + await vi.waitFor(() => expect(listPopup()).not.toBeNull()); + closePopup(); + expect(cancelRequest).toHaveBeenCalledExactlyOnceWith(lspGotoImplementation.mock.lastCall![0]); + + search.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } }); + await pending; + expect(listPopup()).toBeNull(); + expect(document.querySelector(".cm-lsp-hint")).toBeNull(); + }); + + it("goes to the only implementation when none came first", async () => { + const search = slowImplementations(); + const pending = gotoTargets(view, "implementation"); + await vi.waitFor(() => expect(lspGotoImplementation).toHaveBeenCalled()); + search.resolve({ status: "ok", data: [at("/p/Library.java", 8)] }); + await pending; + expect(listPopup()).toBeNull(); + expect(openFile).toHaveBeenCalledWith("/p/Library.java", { selection: { line: 8, col: 0 }, focus: true }); + }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f811faf9..71b2e178 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4048,6 +4048,9 @@ "Choose Super Class or Interface ({count} found)": "选择超类或接口(找到 {count} 个)", "Choose Super Method of {name} ({count} found)": "选择 {name} 的超方法(找到 {count} 个)", "Choose Implementation of {name} ({count} found)": "选择 {name} 的实现(找到 {count} 个)", + "Choose Implementation ({count} found)": "选择实现(找到 {count} 个)", + "Choose Implementation of {name} ({count} found, searching…)": "选择 {name} 的实现(已找到 {count} 个,仍在搜索…)", + "Choose Implementation ({count} found, searching…)": "选择实现(已找到 {count} 个,仍在搜索…)", "Choose Subclass of {name} ({count} found)": "选择 {name} 的子类(找到 {count} 个)", "Choose Overriding Method of {name} ({count} found)": "选择 {name} 的重写方法(找到 {count} 个)", "Is implemented by {names}": "由 {names} 实现",