дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
d2603facdf
коміт
1a008fd17e
13 files changed
+439
-28
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Бінарний файл не відображається.
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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]})
|
||||
|
||||
Reference in new issue
Заблокувати користувача