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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 14:44:48 +08:00
1 джерело d2603facdf
коміт 1a008fd17e
13 files changed
+439 -28

No files matched your search

+7 -6
Переглянути файл
@@ -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 },
+26
Переглянути файл
@@ -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 {
+51 -14
Переглянути файл
@@ -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<LanguageId>,
pub limit: usize,
/// Directories not to look in: the server already analyses every file there.
pub skip: Vec<PathBuf>,
}
#[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<CandidateFiles> {
async fn candidates(
&self,
t: &Target,
word: String,
limit: usize,
skip: Vec<PathBuf>,
cancel: &CancellationToken,
) -> Option<CandidateFiles> {
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<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
let files: Vec<PathBuf> = 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<PathBuf> {
if !has_command(t, COVERED_ROOTS) {
return Vec::new();
}
let params = json!({ "command": COVERED_ROOTS, "arguments": [] });
let roots: IdeResult<Vec<String>> =
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<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
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<String> = 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)
}
+75
Переглянути файл
@@ -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<PathBuf> = (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<String> = 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::<Vec<_>>());
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<Vec<PathBuf>> = 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();
+1 -1
Переглянути файл
@@ -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<PathBuf> {
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);
+1 -1
Переглянути файл
@@ -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,
+17 -2
Переглянути файл
@@ -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
Бінарний файл не відображається.
+3
Переглянути файл
@@ -4,6 +4,9 @@
<delegateCommandHandler class="ai.openidea.usages.CoveredFilesCommand">
<command id="openidea.workspace.coveredFiles"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.usages.CoveredRootsCommand">
<command id="openidea.workspace.coveredRoots"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.usages.UsagesCommand">
<command id="openidea.java.usages"/>
</delegateCommandHandler>
+51
Переглянути файл
@@ -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<Object> 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<String> 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;
}
}
+157
Переглянути файл
@@ -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<Map<String, Object>> rows) {}
private static final Map<String, Entry> DONE = new LinkedHashMap<>(16, 0.75f, true);
private static final Map<String, CompletableFuture<List<Map<String, Object>>>> RUNNING = new HashMap<>();
static {
JavaCore.addElementChangedListener(event -> {
if (affects(event.getDelta())) REVISION.incrementAndGet();
}, ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE);
}
private UsageCache() {}
interface Search {
List<Map<String, Object>> 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<Map<String, Object>> 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<List<Map<String, Object>>> 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<Map<String, Object>> 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<String, ICompilationUnit> 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<Map<String, Object>> search(String key, long revision,
CompletableFuture<List<Map<String, Object>>> running, Search search) throws Exception {
List<Map<String, Object>> 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();
}
}
}
+25 -4
Переглянути файл
@@ -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<Map<String, Object>> respond(Search search, SearchPlan plan, Map<Location, String> 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<Map<String, Object>> response = new ArrayList<>();
UsageAccess access = new UsageAccess(monitor, results);
+25
Переглянути файл
@@ -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]})