From 1a008fd17ea98b2387948f533eb69c5d7fd3ca0c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:44:48 +0800 Subject: [PATCH] perf(java): reuse usage search results and skip covered folders when widening (#2278) Find Usages and usage counts searched again on every request: about two seconds each for a common type such as String. - The Java usages extension (1.12.0) keeps answers by search target until a Java element, the classpath or an unsaved edit changes, and requests for the same target share one search. - Widening checks up to 5000 candidates against the server's analysis scope before applying the 300-file limit, so names mentioned in many covered files no longer report "too many files". - Candidate scans skip the source folders the server covers fully (openidea.workspace.coveredRoots) instead of reading every file. - Commands a server registers after initializing are added to its commands. --- crates/ide-host/src/project.rs | 13 +- crates/ide-lsp/src/caps.rs | 26 +++ crates/ide-lsp/src/scope.rs | 65 ++++++-- crates/ide-lsp/tests/mock_server.rs | 75 +++++++++ crates/ide-sdk/src/java.rs | 2 +- java/java-usages/MANIFEST.MF | 2 +- java/java-usages/README.md | 19 ++- java/java-usages/openidea-java-usages.jar | Bin 142869 -> 160512 bytes java/java-usages/plugin.xml | 3 + .../openidea/usages/CoveredRootsCommand.java | 51 ++++++ .../src/ai/openidea/usages/UsageCache.java | 157 ++++++++++++++++++ .../src/ai/openidea/usages/UsagesCommand.java | 29 +++- java/java-usages/tests/verify.py | 25 +++ 13 files changed, 439 insertions(+), 28 deletions(-) create mode 100644 java/java-usages/src/ai/openidea/usages/CoveredRootsCommand.java create mode 100644 java/java-usages/src/ai/openidea/usages/UsageCache.java diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 80fe600d4..57499866e 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -1069,12 +1069,13 @@ impl Project { let Some(root) = root else { return ide_lsp::CandidateFiles::default(); }; - let query = ide_search::WordQuery { - word: q.word.clone(), - dir: q.root.clone(), - exclude: self.search_excludes(), - limit: q.limit, - }; + let mut exclude = self.search_excludes(); + // Directories the server analyses fully: their files are never read. + exclude.extend(q.skip.iter().filter_map(|dir| { + let rel = dir.strip_prefix(&root).ok()?.to_str()?.replace('\\', "/"); + (!rel.is_empty() && !rel.contains(['*', '?', '[', ']', '{', '}', '!', ','])).then(|| format!("{rel}/**")) + })); + let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude, limit: q.limit }; let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None)); match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) { Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, diff --git a/crates/ide-lsp/src/caps.rs b/crates/ide-lsp/src/caps.rs index ff41c949b..389e6fd07 100644 --- a/crates/ide-lsp/src/caps.rs +++ b/crates/ide-lsp/src/caps.rs @@ -468,6 +468,25 @@ impl ServerCaps { self.restriction = restriction; return; } + // JDT LS registers the commands of bundles that were not loaded when it initialized. + if method == "workspace/executeCommand" + && let Some(added) = options.and_then(|o| o.get("commands")).and_then(Value::as_array) + { + if !self.raw.get("executeCommandProvider").is_some_and(Value::is_object) { + self.raw["executeCommandProvider"] = json!({ "commands": [] }); + } + let provider = &mut self.raw["executeCommandProvider"]; + if !provider.get("commands").is_some_and(Value::is_array) { + provider["commands"] = json!([]); + } + let commands = provider["commands"].as_array_mut().expect("commands"); + for command in added { + if !commands.contains(command) { + commands.push(command.clone()); + } + } + return; + } if method == "textDocument/didSave" { let include = options.and_then(|o| o.get("includeText")).and_then(Value::as_bool).unwrap_or(false); self.save = Some(include); @@ -566,6 +585,13 @@ mod tests { assert!(c.file_operation("willRename").is_some()); c.unregister("workspace/willRenameFiles"); assert!(c.file_operation("willRename").is_none()); + // Commands registered later join those announced at initialization. + let mut c = ServerCaps::parse(json!({ "executeCommandProvider": { "commands": ["a"] } })); + c.register("workspace/executeCommand", Some(&json!({ "commands": ["b", "a"] }))); + assert_eq!(c.commands(), vec!["a", "b"]); + let mut c = ServerCaps::parse(json!({})); + c.register("workspace/executeCommand", Some(&json!({ "commands": ["b"] }))); + assert_eq!(c.commands(), vec!["b"]); } fn restriction(features: &[&str], kinds: &[&str]) -> Restriction { diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs index d2b141b6e..889c68480 100644 --- a/crates/ide-lsp/src/scope.rs +++ b/crates/ide-lsp/src/scope.rs @@ -25,12 +25,17 @@ use std::time::Duration; /// Files opened for one search at most. pub const MAX_CANDIDATES: usize = 300; +/// Candidates checked against a server's analysis scope ([`COVERED_FILES`]) at most: most +/// are usually covered, so a common name only exceeds [`MAX_CANDIDATES`] after that check. +const MAX_SCOPE_CANDIDATES: usize = 5000; /// Larger files (generated code, bundles) are not opened. 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"; +/// Optional server command: directories whose files of its languages it all analyses. +const COVERED_ROOTS: &str = "openidea.workspace.coveredRoots"; /// Project files that may mention a symbol. #[derive(Clone, Debug)] @@ -42,6 +47,8 @@ pub struct CandidateQuery { /// Only files of these languages. pub languages: Vec, pub limit: usize, + /// Directories not to look in: the server already analyses every file there. + pub skip: Vec, } #[derive(Clone, Debug, Default)] @@ -92,14 +99,16 @@ impl LspManager { *self.candidates_hook.write() = Some(Arc::from(hook)); } - async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option { + async fn candidates( + &self, + t: &Target, + word: String, + limit: usize, + skip: Vec, + cancel: &CancellationToken, + ) -> Option { let hook = self.candidates_hook.read().clone()?; - let query = CandidateQuery { - word, - root: t.server.root.clone(), - languages: t.server.languages(), - limit: MAX_CANDIDATES, - }; + let query = CandidateQuery { word, root: t.server.root.clone(), languages: t.server.languages(), limit, skip }; let cancel = cancel.clone(); tokio::task::spawn_blocking(move || hook(&query, &cancel)).await.ok() } @@ -125,11 +134,12 @@ impl LspManager { // gopls searches the view of the file asked about; files opened for it only get views of // their own (see `go_scope`). let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go"); + let reports_scope = has_command(t, COVERED_FILES); + let limit = if reports_scope { MAX_SCOPE_CANDIDATES } else { MAX_CANDIDATES }; let candidates = async { - match word { - Some(w) => self.candidates(t, w, cancel).await, - None => None, - } + let w = word?; + let skip = if reports_scope { self.covered_roots(t, cancel).await } else { Vec::new() }; + self.candidates(t, w, limit, skip, cancel).await }; let (first, candidates) = futures::join!(query(), candidates); let mut first = first?; @@ -148,10 +158,14 @@ 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; + let mut files = self.files_beyond_scope(t, files, cancel).await; if cancel.is_cancelled() { return Err(IdeError::cancelled()); } + if files.len() > MAX_CANDIDATES { + files.truncate(MAX_CANDIDATES); + incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles)); + } if files.is_empty() { return Ok(Widened { first, second: None, incomplete }); } @@ -180,8 +194,25 @@ impl LspManager { } } + /// Directories whose files `t` analyses all ([`COVERED_ROOTS`]); none when it can't say. + async fn covered_roots(&self, t: &Target, cancel: &CancellationToken) -> Vec { + if !has_command(t, COVERED_ROOTS) { + return Vec::new(); + } + let params = json!({ "command": COVERED_ROOTS, "arguments": [] }); + let roots: IdeResult> = + self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await; + match roots { + Ok(roots) => roots.iter().filter_map(|uri| crate::uri::uri_to_path(uri)).collect(), + Err(e) => { + tracing::debug!("{}: covered roots unavailable: {}", t.server.name, e.message); + Vec::new() + } + } + } + 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) { + if files.is_empty() || !has_command(t, COVERED_FILES) { return files; } let uris: Vec = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect(); @@ -224,7 +255,9 @@ impl LspManager { .filter(|l| l.uri.is_none()) .collect(); let Some(declaration) = declarations.first() else { return GoMentions::default() }; - let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() }; + let Some(candidates) = self.candidates(t, word.clone(), MAX_CANDIDATES, Vec::new(), cancel).await else { + return GoMentions::default(); + }; let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf()); let files = tokio::task::spawn_blocking(move || { crate::go_scope::files_beyond_search(&origin, &declared, candidates.files) @@ -478,3 +511,7 @@ impl Drop for TransientFiles<'_> { } } } + +fn has_command(t: &Target, command: &str) -> bool { + t.caps().commands().iter().any(|c| c == command) +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 68cd773ec..69fef3a03 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -3454,6 +3454,81 @@ async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() { } } +/// A common name is mentioned by more files than a search opens, but a server that reports its +/// analysis scope already covers most of them: only those beyond it count toward the limit. +#[tokio::test(flavor = "multi_thread")] +async fn covered_candidates_do_not_count_toward_the_open_limit() { + for covers_all in [true, false] { + let mut caps = incremental_caps(); + caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]); + let h = harness(&[("mock", caps)]); + let s = h.launcher.state("mock"); + let files: Vec = (0..ide_lsp::MAX_CANDIDATES + 100) + .map(|i| { + let path = h.root.join(format!("f{i:03}.mk")); + std::fs::write(&path, "target\n").unwrap(); + path + }) + .collect(); + let covered: Vec = if covers_all { files.iter().map(|f| uri_of(f)).collect() } else { vec![] }; + let responder = open_files_only(vec![]); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if method == "workspace/executeCommand" { + return Some(json!(covered)); + } + responder(s, method, params) + })); + let seen = grep_candidates(&h); + let (doc, _) = h.open("a.mk", "def target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + assert!(seen.lock().iter().all(|q| q.limit > ide_lsp::MAX_CANDIDATES)); + let opened = s.notified("textDocument/didOpen").len() - 1; + if covers_all { + assert_eq!(refs.incomplete, None); + assert_eq!(opened, 0); + assert_eq!(s.requested("textDocument/references").len(), 1); + } else { + assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles)); + assert_eq!(opened, ide_lsp::MAX_CANDIDATES); + } + } +} + +/// Files under the directories a server analyses fully are not read to find candidates. +#[tokio::test(flavor = "multi_thread")] +async fn candidates_skip_the_directories_the_server_covers() { + for (commands, reported) in [ + (json!(["openidea.workspace.coveredFiles", "openidea.workspace.coveredRoots"]), json!(["src"])), + (json!(["openidea.workspace.coveredFiles", "openidea.workspace.coveredRoots"]), json!(null)), + (json!(["openidea.workspace.coveredFiles"]), json!(["src"])), + ] { + let mut caps = incremental_caps(); + caps["executeCommandProvider"]["commands"] = commands.clone(); + let h = harness(&[("mock", caps)]); + let s = h.launcher.state("mock"); + let src = h.root.join("src"); + let roots = reported.as_array().map(|r| r.iter().map(|_| json!(uri_of(&src))).collect::>()); + let responder = open_files_only(vec![]); + *s.responder.lock() = Some(Arc::new(move |s, method, params| { + if method == "workspace/executeCommand" { + return Some(match params["command"].as_str() { + Some("openidea.workspace.coveredRoots") => roots.clone().map_or(json!(null), Value::Array), + _ => json!([]), + }); + } + responder(s, method, params) + })); + let seen = grep_candidates(&h); + let (doc, _) = h.open("a.mk", "def target\n"); + h.wait_status("mock", ServerStatus::Ready).await; + h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap(); + let skipped: Vec> = seen.lock().iter().map(|q| q.skip.clone()).collect(); + let expected = if commands.as_array().unwrap().len() == 2 && reported.is_array() { vec![src] } else { vec![] }; + assert_eq!(skipped, vec![expected], "{commands} {reported}"); + } +} + #[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-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 8403df5ba..8371eb516 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -1508,7 +1508,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.9.0.jar"); + let extension = data_dir.join("java-usages-1.12.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 2247eef91..dffa21313 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.9.0 +Bundle-Version: 1.12.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 371282923..39773681d 100644 --- a/java/java-usages/README.md +++ b/java/java-usages/README.md @@ -27,6 +27,11 @@ from directory names. References, rename and incoming calls leave those files cl 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. +`openidea.workspace.coveredRoots()` returns the URIs of source folders whose Java files the search +scope covers all: no exclusion patterns, no inclusion pattern other than Maven's `**/*.java`, and +enclosed by the test-source policy. The client does not read the files under them when it looks +for candidates, which on a large project took most of a cached search's time. + ## Search plan and access semantics For source fields on the verified JDT LS implementation, a single plan merges @@ -58,7 +63,14 @@ traversals. It indexes only requested exact AST ranges and verified invocation selector-to-call-end spans; duplicate, recovered, malformed or uncontained ranges fall back to NodeFinder. Method references do not get invocation aliases. Source snapshot checks remain per result, and the index is discarded after the query. -There is no cross-request cache or binding-resolution shortcut. + +Answers are cached by their search targets (JDT handles, kinds, declarations), the scope and the +search settings, together with a digest of the editors' unsaved contents, until a Java element +delta reports a change to the sources, a compilation unit or a classpath (`UsageCache`). Opening +or closing an editor and decompiled library classes' private working copies do not invalidate +them. A usage count and the usages shown when it is clicked, or the same search run again, need +no second search, and concurrent requests for the same targets share one. There is no +binding-resolution shortcut. Popup and Find share persisted read/write toolbar switches. Both initially show; turning the last one off automatically enables the other. Unknown and readWrite @@ -76,7 +88,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.9.0.jar`, and added alongside existing initialization bundles. +`java-usages-1.12.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. @@ -118,6 +130,9 @@ longer resolves every Spring Boot property again; its reproducible SHA-256 is Version 1.9.0 adds the analysis-scope command to avoid redundant semantic queries; its reproducible SHA-256 is `ae04182d6a86dfdd6f6befaaeb28907fd9e4c022ceedb445f0b32ea917dca379`. +Version 1.12.0 caches usages answers until the Java model or an unsaved edit changes and adds +`openidea.workspace.coveredRoots`; its reproducible SHA-256 is +`c1bd28d7b956cb64af3e936aac0553413a5dc5fd373a043ddba003009e0ea783`. ```sh export PATH=/path/to/jdk25/bin:$PATH diff --git a/java/java-usages/openidea-java-usages.jar b/java/java-usages/openidea-java-usages.jar index 5d101cb59027653c95d92276d3982d44a40f557c..abfa38710246ca4ec2f463cd81931d062548143c 100644 GIT binary patch delta 16439 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 + + + diff --git a/java/java-usages/src/ai/openidea/usages/CoveredRootsCommand.java b/java/java-usages/src/ai/openidea/usages/CoveredRootsCommand.java new file mode 100644 index 000000000..1686fadec --- /dev/null +++ b/java/java-usages/src/ai/openidea/usages/CoveredRootsCommand.java @@ -0,0 +1,51 @@ +package ai.openidea.usages; + +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.IClasspathEntry; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IPackageFragmentRoot; +import org.eclipse.jdt.core.JavaCore; +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; + +/** + * Directories whose Java files are all in the workspace's semantic source searches: source + * folders without inclusion or exclusion patterns that the search scope encloses. A client + * looking for files beyond that scope need not read the files under them. + */ +public final class CoveredRootsCommand implements IDelegateCommandHandler { + @Override + public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + IJavaProject[] projects = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).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 (IJavaProject project : projects) { + // The default project holds standalone open files, not an indexed source tree. + if (ProjectsManager.getDefaultProject().equals(project.getProject())) continue; + for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + if (root.getKind() != IPackageFragmentRoot.K_SOURCE || root.getResource() == null) continue; + IClasspathEntry entry = root.getRawClasspathEntry(); + if (entry == null || entry.getExclusionPatterns().length > 0) continue; + // Maven projects include `**/*.java`: every Java file, like no pattern at all. + boolean allJava = true; + for (var pattern : entry.getInclusionPatterns()) allJava &= pattern.toString().equals("**/*.java"); + if (!allJava) continue; + if (!scope.encloses(root)) continue; + var location = root.getResource().getLocationURI(); + if (location != null) covered.add(location.toString()); + } + } + return covered; + } +} diff --git a/java/java-usages/src/ai/openidea/usages/UsageCache.java b/java/java-usages/src/ai/openidea/usages/UsageCache.java new file mode 100644 index 000000000..6b5f53440 --- /dev/null +++ b/java/java-usages/src/ai/openidea/usages/UsageCache.java @@ -0,0 +1,157 @@ +package ai.openidea.usages; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.core.ElementChangedEvent; +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.IJavaElement; +import org.eclipse.jdt.core.IJavaElementDelta; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.JavaCore; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.managers.ProjectsManager; + +/** + * Answers of `openidea.java.usages` by search targets, kept until a Java element changes: the + * sources or unsaved edits of a compilation unit, or a project's classpath. A usage count and + * the usages shown when it is clicked, or the same search run again, then need no new search. + * Concurrent requests for the same targets share one search. + */ +final class UsageCache { + private static final int MAX_ENTRIES = 32, MAX_ROWS = 200_000; + /** Opening or closing an editor's working copy and AST-only reconciles change no usage. */ + private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY + | IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED; + private static final AtomicLong REVISION = new AtomicLong(); + private record Entry(long revision, List> rows) {} + private static final Map DONE = new LinkedHashMap<>(16, 0.75f, true); + private static final Map>>> RUNNING = new HashMap<>(); + + static { + JavaCore.addElementChangedListener(event -> { + if (affects(event.getDelta())) REVISION.incrementAndGet(); + }, ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); + } + + private UsageCache() {} + + interface Search { + List> run() throws Exception; + } + + static boolean affects(IJavaElementDelta delta) { + // Decompiled library classes open as private working copies; searches never see them. + if (delta.getElement() instanceof ICompilationUnit unit && unit.getOwner() != null) return false; + // Files outside the source folders (opened to widen a search) join the default project + // while open: they are part of the key instead (`openContents`). + IJavaProject project = delta.getElement().getJavaProject(); + if (project != null && standalone(project)) return false; + int flags = delta.getFlags() & ~IGNORED_FLAGS; + if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT; + if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true; + for (IJavaElementDelta child : delta.getAffectedChildren()) if (affects(child)) return true; + return false; + } + + static List> get(String targets, IProgressMonitor monitor, Search search) throws Exception { + // Searches read unsaved editor contents, which change before the reconcile that reports them. + String key = targets + '|' + openContents(monitor); + while (true) { + long revision = REVISION.get(); + CompletableFuture>> running; + boolean owner = false; + synchronized (UsageCache.class) { + Entry done = DONE.get(key); + if (done != null && done.revision == revision) return done.rows; + running = RUNNING.get(key); + if (running == null) { + running = new CompletableFuture<>(); + RUNNING.put(key, running); + owner = true; + } + } + if (owner) return search(key, revision, running, search); + // The same targets are searched for another request: its answer, or a new search + // when that one was cancelled, failed or saw a change. + while (true) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + try { + List> rows = running.get(50, TimeUnit.MILLISECONDS); + if (REVISION.get() == revision) return rows; + break; + } catch (TimeoutException e) { + continue; + } catch (ExecutionException e) { + break; + } + } + } + } + + private static boolean standalone(IJavaProject project) { + return ProjectsManager.getDefaultProject().equals(project.getProject()); + } + + /** A digest of the editors' unsaved contents and of the open files outside the source + * folders: merely opening or closing a file of the sources keeps it. */ + private static String openContents(IProgressMonitor monitor) throws Exception { + Map copies = new TreeMap<>(); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy); + for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner())) + copies.put(copy.getPrimary().getHandleIdentifier(), copy); + var digest = MessageDigest.getInstance("SHA-256"); + for (var entry : copies.entrySet()) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + ICompilationUnit copy = entry.getValue(); + if (!copy.hasUnsavedChanges() && !standalone(copy.getJavaProject())) continue; + digest.update(entry.getKey().getBytes(StandardCharsets.UTF_8)); + String source = entry.getValue().getSource(); + if (source != null) digest.update(source.getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static List> search(String key, long revision, + CompletableFuture>> running, Search search) throws Exception { + List> rows; + try { + rows = List.copyOf(search.run()); + } catch (Throwable e) { + synchronized (UsageCache.class) { RUNNING.remove(key); } + running.completeExceptionally(e); + throw e; + } + synchronized (UsageCache.class) { + RUNNING.remove(key); + if (REVISION.get() == revision && rows.size() <= MAX_ROWS) remember(key, new Entry(revision, rows)); + } + running.complete(rows); + return rows; + } + + private static void remember(String key, Entry entry) { + DONE.put(key, entry); + int rows = DONE.values().stream().mapToInt(e -> e.rows.size()).sum(); + // Least recently used first; `get` on this access-ordered map would reorder it. + var oldest = DONE.entrySet().iterator(); + while ((DONE.size() > MAX_ENTRIES || rows > MAX_ROWS) && oldest.hasNext()) { + var evicted = oldest.next(); + if (evicted.getKey().equals(key)) continue; + rows -= evicted.getValue().rows.size(); + oldest.remove(); + } + } +} diff --git a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java index 128b1c003..57e61de80 100644 --- a/java/java-usages/src/ai/openidea/usages/UsagesCommand.java +++ b/java/java-usages/src/ai/openidea/usages/UsagesCommand.java @@ -92,13 +92,34 @@ public final class UsagesCommand implements IDelegateCommandHandler { } } } - for (var target : plan.targets.values()) { - if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); - add(search, target.element, target.kind(), target.declarations, results, monitor); - } + UsageCache.Search run = () -> respond(search, plan, results, monitor); + // Unverified builds answer partly through the native handler, before the plan. + return replicated && !plan.targets.isEmpty() + ? UsageCache.get(cacheKey(plan, sources, preferences), monitor, run) : run.run(); } finally { if (targetRoot != null) JDTUtils.discardClassFileWorkingCopy(targetRoot); } + } + + /** What a search depends on besides the Java model: its targets, scope and search settings. */ + private static String cacheKey(SearchPlan plan, boolean sources, + org.eclipse.jdt.ls.core.internal.preferences.PreferenceManager preferences) { + var settings = preferences.getPreferences(); + StringBuilder key = new StringBuilder().append(sources).append('|').append(settings.getSearchScope()) + .append('|').append(preferences.isClientSupportsClassFileContent() && settings.isIncludeDecompiledSources()); + for (var target : plan.targets.values()) { + key.append('|').append(target.element.getHandleIdentifier()).append('#').append(target.kind()) + .append('#').append(target.declarations); + } + return key.toString(); + } + + private static List> respond(Search search, SearchPlan plan, Map results, + IProgressMonitor monitor) throws Exception { + for (var target : plan.targets.values()) { + if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); + add(search, target.element, target.kind(), target.declarations, results, monitor); + } if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); List> response = new ArrayList<>(); UsageAccess access = new UsageAccess(monitor, results); diff --git a/java/java-usages/tests/verify.py b/java/java-usages/tests/verify.py index 9a4188126..abdf25064 100644 --- a/java/java-usages/tests/verify.py +++ b/java/java-usages/tests/verify.py @@ -136,6 +136,31 @@ try: line, text = next((i, t) for i, t in enumerate(source.splitlines()) if 'println' in t) evidence['jdkSources'] = assert_sources(line, text.index('println')) assert evidence['jdkSources'], 'no JDK method usages in the sources' + # Answers are reused until the sources change: an unsaved edit is seen at once, before its reconcile. + line, text = next((i, t) for i, t in enumerate(source.splitlines()) if 'private String name;' in t) + usages = lambda: request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,text.index('name'),False]}) + getters = lambda rows: [r['kind'] for r in rows].count('getter') + first = usages() + assert usages() == first + edited = source.replace(' void calls(Bean b)', ' void more(Bean b) { b.getName(); }\n void calls(Bean b)') + send({'jsonrpc':'2.0','method':'textDocument/didChange','params':{'textDocument':{'uri':file.as_uri(),'version':2},'contentChanges':[{'text':edited}]}}) + changed = usages() + assert getters(changed) == getters(first) + 1, (first, changed) + send({'jsonrpc':'2.0','method':'textDocument/didChange','params':{'textDocument':{'uri':file.as_uri(),'version':3},'contentChanges':[{'text':source}]}}) + assert usages() == first + # The source folder holds only covered files; the folder beside it does not. + roots = request('workspace/executeCommand', {'command':'openidea.workspace.coveredRoots','arguments':[]}) + assert [pathlib.Path(r.replace('file:', '', 1)).resolve() for r in roots] == [(project / 'src').resolve()], roots + evidence['coveredRoots'] = roots + # A file outside the source folders, opened and closed to widen a search, leaves the answer as it was. + extra = project / 'other' / 'Extra.java' + extra.parent.mkdir() + extra.write_text('class Extra { void use(Bean b) { b.getName(); } }\n') + send({'jsonrpc':'2.0','method':'textDocument/didOpen','params':{'textDocument':{'uri':extra.as_uri(),'languageId':'java','version':1,'text':extra.read_text()}}}) + widened = usages() + send({'jsonrpc':'2.0','method':'textDocument/didClose','params':{'textDocument':{'uri':extra.as_uri()}}}) + assert usages() == first + evidence['cached'] = {'first':first, 'edited':changed, 'widened':widened} # Respect the existing global preference: the command must not switch it on. send({'jsonrpc':'2.0','method':'workspace/didChangeConfiguration','params':{'settings':{'java':{'references':{'includeAccessors':False}}}}}) disabled = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),3,19,False]})