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 5702ed6c..8208527b 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/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} 实现",