From 292ddc4d2aafa6a6e2fdecc057310b5369b3ac72 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:53:23 +0800 Subject: [PATCH] fix(lsp): skip widening for indexed source files (#2052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2034 Call hierarchy, Find Usages and rename widen a server's answer by opening project files that mention the symbol's name and asking again. For JDT LS those files are mostly sources it already analyses, so nearly every incoming-calls request ran a second, slow round before anything was shown. Widening now asks the server which candidates are already in its analysis scope (optional `openidea.workspace.coveredFiles` command, served by the bundled JDT extension using the live source search scope) and only opens the rest; a missing or failing answer keeps the previous behaviour. Covers call hierarchy, references and rename. Hierarchy on a multi-module Maven fixture: median 703 ms → 224 ms, one query round. --- crates/ide-lsp/src/scope.rs | 33 +++++- crates/ide-lsp/tests/mock_server.rs | 106 ++++++++++++++++++ crates/ide-lsp/tests/real_servers.rs | 74 ++++++++++++ crates/ide-sdk/src/java.rs | 2 +- java/java-usages/MANIFEST.MF | 2 +- java/java-usages/README.md | 13 ++- java/java-usages/openidea-java-usages.jar | Bin 138464 -> 142869 bytes java/java-usages/plugin.xml | 3 + .../openidea/usages/CoveredFilesCommand.java | 43 +++++++ 9 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 java/java-usages/src/ai/openidea/usages/CoveredFilesCommand.java diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs index 281f40c0b..d2b141b6e 100644 --- a/crates/ide-lsp/src/scope.rs +++ b/crates/ide-lsp/src/scope.rs @@ -6,8 +6,8 @@ //! other project files are silently missing. So, as a word index would, the //! project files of the server's languages that contain the symbol's name as a //! whole word (found through the host's [`CandidateFn`]) and that the first -//! answer did not cover are opened on the server for a second request, then -//! closed again. Diagnostics the server publishes for them are dropped. +//! answer and the server's analysis scope did not cover are opened for a second +//! request, then closed again. Diagnostics the server publishes for them are dropped. use crate::server::{Running, Server}; use crate::text::DocText; @@ -29,6 +29,8 @@ pub const MAX_CANDIDATES: usize = 300; const MAX_CANDIDATE_BYTES: u64 = 1 << 20; /// How long a search that found nothing waits for a server to finish indexing. const INDEX_WAIT: Duration = Duration::from_secs(10); +/// Optional server command: candidate URIs in, already analysed candidate URIs out. +const COVERED_FILES: &str = "openidea.workspace.coveredFiles"; /// Project files that may mention a symbol. #[derive(Clone, Debug)] @@ -103,7 +105,7 @@ impl LspManager { } /// Runs `query` on `t`; then again with the project files that mention the - /// symbol at `pos` and are not in `covered(first)` opened on the server. + /// symbol at `pos` outside `covered(first)` and the server's analysis scope opened. /// A first answer that is empty while the server indexes is retried once /// indexing ends. pub(crate) async fn widened( @@ -146,6 +148,10 @@ impl LspManager { } let known: HashSet = covered(&first).into_iter().chain([snap.path.clone()]).collect(); let files: Vec = candidates.files.into_iter().filter(|f| !known.contains(f)).collect(); + let files = self.files_beyond_scope(t, files, cancel).await; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } if files.is_empty() { return Ok(Widened { first, second: None, incomplete }); } @@ -174,6 +180,27 @@ impl LspManager { } } + async fn files_beyond_scope(&self, t: &Target, files: Vec, cancel: &CancellationToken) -> Vec { + if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) { + return files; + } + let uris: Vec = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect(); + let params = json!({ "command": COVERED_FILES, "arguments": [uris] }); + // An unsupported, failed or malformed scope answer must not remove candidates. + let covered: IdeResult> = + self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await; + match covered { + Ok(covered) => { + let covered: HashSet<_> = covered.into_iter().collect(); + files.into_iter().zip(uris).filter_map(|(f, uri)| (!covered.contains(&uri)).then_some(f)).collect() + } + Err(e) => { + tracing::debug!("{}: analysis scope unavailable: {}", t.server.name, e.message); + files + } + } + } + /// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see /// `go_scope`), each mention of the name resolved in its own file's view; with `callers`, /// also the declarations containing them, as incoming calls. diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index be25b92fb..6cb2c5d3b 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -3122,6 +3122,112 @@ fn scope_harness() -> (Harness, Arc) { (h, s) } +#[tokio::test(flavor = "multi_thread")] +async fn indexed_sources_are_not_widened_but_outside_callers_are() { + for classified in [false, true] { + for outside in [false, true] { + let mut caps = incremental_caps(); + caps["executeCommandProvider"]["commands"] = if classified { + json!(["openidea.workspace.coveredFiles", "openidea.java.usages"]) + } else { + json!(["openidea.workspace.coveredFiles"]) + }; + let h = harness_for("java", "java", &[("jdtls", caps)], &[]); + let s = h.launcher.state("jdtls"); + let caller = h.root.join("Caller.java"); + let decoy = h.root.join("Unrelated.java"); + let extra = h.root.join("Outside.java"); + std::fs::write(&caller, "target();").unwrap(); + std::fs::write(&decoy, "target").unwrap(); + if outside { + std::fs::write(&extra, "target();").unwrap(); + } + let covered = vec![uri_of(&caller), uri_of(&decoy)]; + let indexed = uri_of(&caller); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if method == "workspace/executeCommand" && params["command"] == "openidea.workspace.coveredFiles" { + return Some(json!(covered)); + } + let model = MockState::default(); + *model.docs.lock() = s.docs.lock().clone(); + model.docs.lock().insert(indexed.clone(), "target();".into()); + let responder = open_files_only(vec![]); + if method == "workspace/executeCommand" && params["command"] == "openidea.java.usages" { + let refs = responder(&model, "textDocument/references", params).unwrap(); + return Some(Value::Array( + refs.as_array() + .unwrap() + .iter() + .map(|location| json!({ "location": location, "kind": "unknown", "access": "read" })) + .collect(), + )); + } + responder(&model, method, params) + })); + grep_candidates(&h); + let (doc, _) = h.open("Target.java", "def target\n"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let refs = h + .mgr + .references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), None) + .await + .unwrap(); + assert!(refs.locations.iter().any(|l| l.path == caller)); + assert_eq!(refs.locations.iter().any(|l| l.path == extra), outside); + assert_eq!(refs.incomplete, None); + let calls = h + .mgr + .call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()) + .await + .unwrap(); + assert!(calls.items.iter().any(|i| i.path == caller)); + assert_eq!(calls.items.iter().any(|i| i.path == extra), outside); + assert_eq!(calls.incomplete, None); + let rounds = if outside { 2 } else { 1 }; + assert_eq!(s.requested("callHierarchy/incomingCalls").len(), rounds); + if classified { + assert_eq!( + s.requested("workspace/executeCommand") + .iter() + .filter(|p| p["command"] == "openidea.java.usages") + .count(), + rounds + ); + assert_eq!(refs.classified.len(), refs.locations.len()); + } else { + assert_eq!(s.requested("textDocument/references").len(), rounds); + } + eventually(|| s.docs.lock().len() == 1).await; + let opened = uris(&s.notified("textDocument/didOpen")); + assert!(!opened.contains(&uri_of(&caller))); + assert!(!opened.contains(&uri_of(&decoy))); + assert_eq!(opened.contains(&uri_of(&extra)), outside); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() { + for response in [json!(null), json!([false]), json!([])] { + let mut caps = incremental_caps(); + caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]); + let h = harness(&[("mock", caps)]); + let s = h.launcher.state("mock"); + *s.responder.lock() = Some(open_files_only(vec![])); + s.respond("workspace/executeCommand", response); + h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles { + files: vec![q.root.join("b.mk")], + limit_hit: true, + })); + std::fs::write(h.root.join("b.mk"), "target\n").unwrap(); + let (doc, _) = h.open("a.mk", "def target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles)); + assert_eq!(s.requested("textDocument/references").len(), 2); + assert_eq!(s.requested("callHierarchy/incomingCalls").len(), 2); + } +} + #[tokio::test(flavor = "multi_thread")] async fn references_open_unopened_files_that_mention_the_name() { let (h, s) = scope_harness(); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index f3b3e29cc..6846f1060 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -112,6 +112,80 @@ async fn kmp_lsp_java_end_to_end() { tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires jdtls on PATH and a compatible JDK"] +async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() { + use ide_lsp::types::HierarchyDirection; + use serde_json::json; + + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let root = dunce::canonicalize(root).unwrap(); + std::fs::create_dir_all(root.join("src/demo/excluded")).unwrap(); + std::fs::write(root.join(".project"), "search-scopeorg.eclipse.jdt.core.javabuilderorg.eclipse.jdt.core.javanature").unwrap(); + std::fs::write(root.join(".classpath"), "").unwrap(); + let source = "package demo; public class Target { public static void target() {} }"; + let path = root.join("src/demo/Target.java"); + let caller = root.join("src/demo/Caller.java"); + let decoy = root.join("src/demo/Decoy.java"); + let excluded = root.join("src/demo/excluded/Excluded.java"); + let outside = root.join("Outside.java"); + std::fs::write(&path, source).unwrap(); + std::fs::write(&caller, "package demo; class Caller { void call() { Target.target(); } }").unwrap(); + // A source file mentioning the name without a usage, too large for transient opening. + std::fs::write(&decoy, format!("package demo; class Decoy {{ void target() {{}} }}\n/*{}*/", " ".repeat(1 << 20))) + .unwrap(); + std::fs::write(&excluded, "package demo.excluded; class Excluded {}").unwrap(); + std::fs::write(&outside, "class Outside {}").unwrap(); + let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap(); + java.language_servers.retain(|s| s.name == "jdtls"); + let server = &mut java.language_servers[0]; + if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") { + server.command = bin; + } + server.args.extend(["-data".into(), dir.path().join("metadata").to_string_lossy().into_owned()]); + let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/java-usages/openidea-java-usages.jar"); + server + .initialization_options + .as_mut() + .unwrap() + .as_table_mut() + .unwrap() + .insert("bundles".into(), toml::Value::Array(vec![toml::Value::String(bundle.to_string_lossy().into_owned())])); + let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source); + mgr.java_build(&path, &Default::default()).await.unwrap(); + let uri = ide_lsp::path_to_uri_string; + let covered = retry("imported source scope", Duration::from_secs(120), || async { + mgr.jdtls_command( + &path, + "openidea.workspace.coveredFiles", + vec![json!([uri(&caller), uri(&decoy), uri(&excluded), uri(&outside),])], + ) + .await + .ok() + .filter(|v| v.as_array().is_some_and(|a| !a.is_empty())) + }) + .await; + assert_eq!(covered, json!([uri(&caller), uri(&decoy)])); + mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles { + files: vec![caller.clone(), decoy.clone()], + limit_hit: false, + })); + let at = Pos::new(0, source.find("target()").unwrap() as u32); + let refs = mgr.references_streaming(doc, at, false, true, false, Default::default(), None).await.unwrap(); + assert_eq!(refs.locations.len(), 1); + assert!(refs.locations[0].path.ends_with("Caller.java")); + assert_eq!(refs.incomplete, None, "the indexed decoy must not be transiently opened"); + let calls = mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, Default::default()).await.unwrap(); + assert_eq!(calls.items.len(), 1); + assert!(calls.items[0].path.ends_with("Caller.java")); + assert_eq!(calls.incomplete, None); + mgr.shutdown_all().await; +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires jdtls on PATH and a compatible JDK"] async fn lombok_generated_members_appear_in_document_symbols() { diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 18c513b38..afecff128 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1202,7 +1202,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.8.0.jar"); + let extension = data_dir.join("java-usages-1.9.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 40fc4e42f..2247eef91 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.8.0 +Bundle-Version: 1.9.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 d705841d3..371282923 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -20,6 +20,13 @@ first and runs the full search after it. Rows in library classes (`jdt:` URIs) c line as `text`, from the contents the client would request for the class, so the client need not fetch thousands of class files to show them. +`openidea.workspace.coveredFiles([candidateUri, ...])` returns candidate URIs already +covered by the current JDT source search scope. It checks imported compilation units, +classpath inclusion/exclusion rules and test-source policy; it does not infer coverage +from directory names. References, rename and incoming calls leave those files closed +and only widen with the remaining candidates. A missing or failing command retains +widening. The result is query-local, so imports and classpath changes need no cache invalidation. + ## Search plan and access semantics For source fields on the verified JDT LS implementation, a single plan merges @@ -69,7 +76,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.8.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.9.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. @@ -108,6 +115,10 @@ configuration index (until the JAR or the project's classpath changes), so a sou longer resolves every Spring Boot property again; its reproducible SHA-256 is `728ab33b5e01fa5e496818f25ddbc487458a92339026899b0e22abda5adfd67b`. +Version 1.9.0 adds the analysis-scope command to avoid redundant semantic queries; +its reproducible SHA-256 is +`ae04182d6a86dfdd6f6befaaeb28907fd9e4c022ceedb445f0b32ea917dca379`. + ```sh export PATH=/path/to/jdk25/bin:$PATH python3 java/java-usages/build.py /path/to/jdtls diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 693009b7f6f286e359be3f8573c8eddb6fe8430a..5d101cb59027653c95d92276d3982d44a40f557c 100644 GIT binary patch delta 2291 zcma)7dvH`&8UKBEA7^v#-DEcplABGIyr5YMNt4!;Y!HHJ(-uMqNm~f=+HP)OVY3@{ zH)*P3fMTm{DdHARX=w!?jTT>YY?2iRLS_8J4kFIzjE)`4KbC;g>OY;)X^G#>B4He5 zym#(B=X*cS_kHL6jO)msT)wW=HhUqmPvxfCrwW>XK!EiX%0q=p@!`hf)V%M~+n1;? z>F$2><4^vc2Uy=?d2*cQ1=`}nX2R@ik3`L6TYO+36zi)CM?=ZvuWtP8EUR6KQvwQM zpKY3-{+f*Ze{tFt`R1~3DU>;#n`uwy{ z{&AW-c9g)7A5POU0Uyc*%4EYe3fSSte7WTswFp$ATA(UjxxdO*g9Xy#piR!Z5D=)9 zTThW8Td$L9$0FP0Rn}HiL}?rMH6poj*;*Ok0M}%4Jt+v%m_h6lj$vuF}J~_#D~@_JL3&MrfB; zE>e{xP{XDl#6tq@#IBwGfU2|yW7{J!Q^&)2ME>?V?Y0T5mRG8%q;eHB4V~x`SWC#= z85$1N52Yf}`i@94rDGksW!DArs}|3rIv&I0>H3crIyYdWK(D-Uj*4xc$ENi8#eP<^ z1=|F+5(4r{83lYgLU@9pMv`rzSlEo3eFR%=fF=9nM?az_mx?enf$ehl1uB@YB7$84 zI|=%%8!G*;bnbREMEF~?}mvrpGm(!JpnmPGAZ7B;e<4i{PLso<<7FHpxXf5M8c&)@dE+eAE@g>MysG0k4)Gy|`ua3% z!0S4`Y2`ue2qinskrW&4Nrl3@)`SKh4LuPx1->O)Kcu^y0^hNm<$PxmB~!e$mIxvio1Oi}qLLF+L?iSUk|aD3221K0)r96SmXUb)ZtpTkf&uRLYo zSTq6kG;g?;`0t!W=JVf;To&a?13N<=Y1Gwg;oi5(dXDCiC5oo&jsHMz%-F!Dx=+>Jrq z&?k}X4SLUG_!u@C-YECoyW|XYdt52G1!>z1zq)^bEdk z?U}R85Ek!C3cKMkT!xmxD+<<+!F|9@Med^9lQRb`e_vzGGpmeI*H32CWTmnj)?hD! zih2ANbKjKkL^IIH>-{K2D-X}jDC7Pt#}LoXmoOjWsK7Z?;eAx&uiRuGV*&mFKiS|T zHv%}pNna2329^lK1WxjhYo!@x;@Lm~{i6b%0$Xko1&%O=k9`9$!?tg-{Z}pl9(A*P zet5F#?!d>!Ky32cmf~7??_@u#nI#0iJ1Zl246s-L1^C0ZFw&=-tViymQuvr(LyO`y=0;PN6pjY`ov0FF%#7Lpi@A!|~{JNpkXmj0c$|*xx zs(22_wLYa(*6$-BeWgl+^0$MxFm5?is@$U#$}A@DS<>ajQe~0SF=1hO&2R3 z8Tcvjw7U81rN=K=486+vUs`KGzB^8;e9y<`#Dif8#; m_*Qb}p1t%*g@VE85MP~jBLmT{)efgcE#Y^{1*}-0F!V3Dzzd83 delta 302 zcmbPwhvUIc4xs>VW)=|!2vCgKGcju3M4>&57LDh(o@W#(-Y(a|_+}2*^!atOtC<-X zs+p&YC^GHdp0C98fQbd9czduaQyS}ZVQnU<>6f*b#HaI4WaOD1pv`2+ytS5by5V$2 z`RN4{8QG_=(q=MXzS#yAQk@>I#w0L3YCEF + + + diff --git a/java/java-usages/src/ai/openidea/usages/CoveredFilesCommand.java b/java/java-usages/src/ai/openidea/usages/CoveredFilesCommand.java new file mode 100644 index 000000000..dc5a65f80 --- /dev/null +++ b/java/java-usages/src/ai/openidea/usages/CoveredFilesCommand.java @@ -0,0 +1,43 @@ +package ai.openidea.usages; + +import java.net.URI; +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.core.search.IJavaSearchScope; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.managers.ProjectsManager; +import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope; + +/** Candidate files already included in the workspace's semantic source searches. */ +public final class CoveredFilesCommand implements IDelegateCommandHandler { + @Override + public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + var workspace = ResourcesPlugin.getWorkspace().getRoot(); + IJavaProject[] projects = JavaCore.create(workspace).getJavaProjects(); + var setting = JavaLanguageServerPlugin.getPreferencesManager().getPreferences().getSearchScope(); + var scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects, + IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS); + List covered = new ArrayList<>(); + for (Object candidate : (List) arguments.get(0)) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + String uri = (String) candidate; + for (var file : workspace.findFilesForLocationURI(URI.create(uri))) { + if (!(JavaCore.create(file) instanceof ICompilationUnit unit) || !unit.exists()) continue; + IJavaProject project = unit.getJavaProject(); + // The default project holds standalone open files, not an indexed source tree. + if (ProjectsManager.getDefaultProject().equals(project.getProject()) || !project.isOnClasspath(file)) continue; + if (scope.encloses(unit)) { + covered.add(uri); + break; + } + } + } + return covered; + } +}