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 5d101cb59..abfa38710 100644 Binary files a/java/java-usages/openidea-java-usages.jar and b/java/java-usages/openidea-java-usages.jar differ diff --git a/java/java-usages/plugin.xml b/java/java-usages/plugin.xml index 3a2f2ea4e..54819bc98 100644 --- a/java/java-usages/plugin.xml +++ b/java/java-usages/plugin.xml @@ -4,6 +4,9 @@ + + + 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]})