дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(lsp): skip widening for indexed source files (#2052)
Fixes #2034 Call hierarchy, Find Usages and rename widen a server's answer by opening project files that mention the symbol's name and asking again. For JDT LS those files are mostly sources it already analyses, so nearly every incoming-calls request ran a second, slow round before anything was shown. Widening now asks the server which candidates are already in its analysis scope (optional `openidea.workspace.coveredFiles` command, served by the bundled JDT extension using the live source search scope) and only opens the rest; a missing or failing answer keeps the previous behaviour. Covers call hierarchy, references and rename. Hierarchy on a multi-module Maven fixture: median 703 ms → 224 ms, one query round.
Цей коміт міститься в:
1 джерело
af19880390
коміт
292ddc4d2a
9 files changed
+270
-6
No files matched your search
@@ -6,8 +6,8 @@
|
||||
//! other project files are silently missing. So, as a word index would, the
|
||||
//! project files of the server's languages that contain the symbol's name as a
|
||||
//! whole word (found through the host's [`CandidateFn`]) and that the first
|
||||
//! answer did not cover are opened on the server for a second request, then
|
||||
//! closed again. Diagnostics the server publishes for them are dropped.
|
||||
//! answer and the server's analysis scope did not cover are opened for a second
|
||||
//! request, then closed again. Diagnostics the server publishes for them are dropped.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::text::DocText;
|
||||
@@ -29,6 +29,8 @@ pub const MAX_CANDIDATES: usize = 300;
|
||||
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
|
||||
/// How long a search that found nothing waits for a server to finish indexing.
|
||||
const INDEX_WAIT: Duration = Duration::from_secs(10);
|
||||
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
|
||||
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
|
||||
|
||||
/// Project files that may mention a symbol.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -103,7 +105,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
/// Runs `query` on `t`; then again with the project files that mention the
|
||||
/// symbol at `pos` and are not in `covered(first)` opened on the server.
|
||||
/// symbol at `pos` outside `covered(first)` and the server's analysis scope opened.
|
||||
/// A first answer that is empty while the server indexes is retried once
|
||||
/// indexing ends.
|
||||
pub(crate) async fn widened<R, F, Fut>(
|
||||
@@ -146,6 +148,10 @@ 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;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
@@ -174,6 +180,27 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
|
||||
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
|
||||
return files;
|
||||
}
|
||||
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
|
||||
let params = json!({ "command": COVERED_FILES, "arguments": [uris] });
|
||||
// An unsupported, failed or malformed scope answer must not remove candidates.
|
||||
let covered: IdeResult<Vec<String>> =
|
||||
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
match covered {
|
||||
Ok(covered) => {
|
||||
let covered: HashSet<_> = covered.into_iter().collect();
|
||||
files.into_iter().zip(uris).filter_map(|(f, uri)| (!covered.contains(&uri)).then_some(f)).collect()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: analysis scope unavailable: {}", t.server.name, e.message);
|
||||
files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see
|
||||
/// `go_scope`), each mention of the name resolved in its own file's view; with `callers`,
|
||||
/// also the declarations containing them, as incoming calls.
|
||||
|
||||
@@ -3122,6 +3122,112 @@ fn scope_harness() -> (Harness, Arc<MockState>) {
|
||||
(h, s)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn indexed_sources_are_not_widened_but_outside_callers_are() {
|
||||
for classified in [false, true] {
|
||||
for outside in [false, true] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"]["commands"] = if classified {
|
||||
json!(["openidea.workspace.coveredFiles", "openidea.java.usages"])
|
||||
} else {
|
||||
json!(["openidea.workspace.coveredFiles"])
|
||||
};
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let caller = h.root.join("Caller.java");
|
||||
let decoy = h.root.join("Unrelated.java");
|
||||
let extra = h.root.join("Outside.java");
|
||||
std::fs::write(&caller, "target();").unwrap();
|
||||
std::fs::write(&decoy, "target").unwrap();
|
||||
if outside {
|
||||
std::fs::write(&extra, "target();").unwrap();
|
||||
}
|
||||
let covered = vec![uri_of(&caller), uri_of(&decoy)];
|
||||
let indexed = uri_of(&caller);
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
if method == "workspace/executeCommand" && params["command"] == "openidea.workspace.coveredFiles" {
|
||||
return Some(json!(covered));
|
||||
}
|
||||
let model = MockState::default();
|
||||
*model.docs.lock() = s.docs.lock().clone();
|
||||
model.docs.lock().insert(indexed.clone(), "target();".into());
|
||||
let responder = open_files_only(vec![]);
|
||||
if method == "workspace/executeCommand" && params["command"] == "openidea.java.usages" {
|
||||
let refs = responder(&model, "textDocument/references", params).unwrap();
|
||||
return Some(Value::Array(
|
||||
refs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|location| json!({ "location": location, "kind": "unknown", "access": "read" }))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
responder(&model, method, params)
|
||||
}));
|
||||
grep_candidates(&h);
|
||||
let (doc, _) = h.open("Target.java", "def target\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(refs.locations.iter().any(|l| l.path == caller));
|
||||
assert_eq!(refs.locations.iter().any(|l| l.path == extra), outside);
|
||||
assert_eq!(refs.incomplete, None);
|
||||
let calls = h
|
||||
.mgr
|
||||
.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(calls.items.iter().any(|i| i.path == caller));
|
||||
assert_eq!(calls.items.iter().any(|i| i.path == extra), outside);
|
||||
assert_eq!(calls.incomplete, None);
|
||||
let rounds = if outside { 2 } else { 1 };
|
||||
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), rounds);
|
||||
if classified {
|
||||
assert_eq!(
|
||||
s.requested("workspace/executeCommand")
|
||||
.iter()
|
||||
.filter(|p| p["command"] == "openidea.java.usages")
|
||||
.count(),
|
||||
rounds
|
||||
);
|
||||
assert_eq!(refs.classified.len(), refs.locations.len());
|
||||
} else {
|
||||
assert_eq!(s.requested("textDocument/references").len(), rounds);
|
||||
}
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let opened = uris(&s.notified("textDocument/didOpen"));
|
||||
assert!(!opened.contains(&uri_of(&caller)));
|
||||
assert!(!opened.contains(&uri_of(&decoy)));
|
||||
assert_eq!(opened.contains(&uri_of(&extra)), outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
|
||||
for response in [json!(null), json!([false]), json!([])] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
|
||||
let h = harness(&[("mock", caps)]);
|
||||
let s = h.launcher.state("mock");
|
||||
*s.responder.lock() = Some(open_files_only(vec![]));
|
||||
s.respond("workspace/executeCommand", response);
|
||||
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
|
||||
files: vec![q.root.join("b.mk")],
|
||||
limit_hit: true,
|
||||
}));
|
||||
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
expect_all(incomplete_reports(&h, doc).await, Some(ReferencesIncomplete::TooManyFiles));
|
||||
assert_eq!(s.requested("textDocument/references").len(), 2);
|
||||
assert_eq!(s.requested("callHierarchy/incomingCalls").len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_open_unopened_files_that_mention_the_name() {
|
||||
let (h, s) = scope_harness();
|
||||
|
||||
@@ -112,6 +112,80 @@ async fn kmp_lsp_java_end_to_end() {
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn java_searches_skip_indexed_sources_but_keep_exclusions_outside_scope() {
|
||||
use ide_lsp::types::HierarchyDirection;
|
||||
use serde_json::json;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let root = dunce::canonicalize(root).unwrap();
|
||||
std::fs::create_dir_all(root.join("src/demo/excluded")).unwrap();
|
||||
std::fs::write(root.join(".project"), "<projectDescription><name>search-scope</name><buildSpec><buildCommand><name>org.eclipse.jdt.core.javabuilder</name></buildCommand></buildSpec><natures><nature>org.eclipse.jdt.core.javanature</nature></natures></projectDescription>").unwrap();
|
||||
std::fs::write(root.join(".classpath"), "<classpath><classpathentry kind=\"src\" path=\"src\" excluding=\"demo/excluded/**\"/><classpathentry kind=\"con\" path=\"org.eclipse.jdt.launching.JRE_CONTAINER\"/><classpathentry kind=\"output\" path=\"bin\"/></classpath>").unwrap();
|
||||
let source = "package demo; public class Target { public static void target() {} }";
|
||||
let path = root.join("src/demo/Target.java");
|
||||
let caller = root.join("src/demo/Caller.java");
|
||||
let decoy = root.join("src/demo/Decoy.java");
|
||||
let excluded = root.join("src/demo/excluded/Excluded.java");
|
||||
let outside = root.join("Outside.java");
|
||||
std::fs::write(&path, source).unwrap();
|
||||
std::fs::write(&caller, "package demo; class Caller { void call() { Target.target(); } }").unwrap();
|
||||
// A source file mentioning the name without a usage, too large for transient opening.
|
||||
std::fs::write(&decoy, format!("package demo; class Decoy {{ void target() {{}} }}\n/*{}*/", " ".repeat(1 << 20)))
|
||||
.unwrap();
|
||||
std::fs::write(&excluded, "package demo.excluded; class Excluded {}").unwrap();
|
||||
std::fs::write(&outside, "class Outside {}").unwrap();
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
java.language_servers.retain(|s| s.name == "jdtls");
|
||||
let server = &mut java.language_servers[0];
|
||||
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
|
||||
server.command = bin;
|
||||
}
|
||||
server.args.extend(["-data".into(), dir.path().join("metadata").to_string_lossy().into_owned()]);
|
||||
let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../java/java-usages/openidea-java-usages.jar");
|
||||
server
|
||||
.initialization_options
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.insert("bundles".into(), toml::Value::Array(vec![toml::Value::String(bundle.to_string_lossy().into_owned())]));
|
||||
let (mgr, _) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
mgr.java_build(&path, &Default::default()).await.unwrap();
|
||||
let uri = ide_lsp::path_to_uri_string;
|
||||
let covered = retry("imported source scope", Duration::from_secs(120), || async {
|
||||
mgr.jdtls_command(
|
||||
&path,
|
||||
"openidea.workspace.coveredFiles",
|
||||
vec![json!([uri(&caller), uri(&decoy), uri(&excluded), uri(&outside),])],
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|v| v.as_array().is_some_and(|a| !a.is_empty()))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(covered, json!([uri(&caller), uri(&decoy)]));
|
||||
mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
|
||||
files: vec![caller.clone(), decoy.clone()],
|
||||
limit_hit: false,
|
||||
}));
|
||||
let at = Pos::new(0, source.find("target()").unwrap() as u32);
|
||||
let refs = mgr.references_streaming(doc, at, false, true, false, Default::default(), None).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 1);
|
||||
assert!(refs.locations[0].path.ends_with("Caller.java"));
|
||||
assert_eq!(refs.incomplete, None, "the indexed decoy must not be transiently opened");
|
||||
let calls = mgr.call_hierarchy(doc, at, HierarchyDirection::Incoming, Default::default()).await.unwrap();
|
||||
assert_eq!(calls.items.len(), 1);
|
||||
assert!(calls.items[0].path.ends_with("Caller.java"));
|
||||
assert_eq!(calls.incomplete, None);
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn lombok_generated_members_appear_in_document_symbols() {
|
||||
|
||||
@@ -1202,7 +1202,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.8.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.9.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.8.0
|
||||
Bundle-Version: 1.9.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -20,6 +20,13 @@ first and runs the full search after it. Rows in library classes (`jdt:` URIs) c
|
||||
line as `text`, from the contents the client would request for the class, so the client
|
||||
need not fetch thousands of class files to show them.
|
||||
|
||||
`openidea.workspace.coveredFiles([candidateUri, ...])` returns candidate URIs already
|
||||
covered by the current JDT source search scope. It checks imported compilation units,
|
||||
classpath inclusion/exclusion rules and test-source policy; it does not infer coverage
|
||||
from directory names. References, rename and incoming calls leave those files closed
|
||||
and only widen with the remaining candidates. A missing or failing command retains
|
||||
widening. The result is query-local, so imports and classpath changes need no cache invalidation.
|
||||
|
||||
## Search plan and access semantics
|
||||
|
||||
For source fields on the verified JDT LS implementation, a single plan merges
|
||||
@@ -69,7 +76,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.8.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.9.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -108,6 +115,10 @@ configuration index (until the JAR or the project's classpath changes), so a sou
|
||||
longer resolves every Spring Boot property again; its reproducible SHA-256 is
|
||||
`728ab33b5e01fa5e496818f25ddbc487458a92339026899b0e22abda5adfd67b`.
|
||||
|
||||
Version 1.9.0 adds the analysis-scope command to avoid redundant semantic queries;
|
||||
its reproducible SHA-256 is
|
||||
`ae04182d6a86dfdd6f6befaaeb28907fd9e4c022ceedb445f0b32ea917dca379`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
python3 java/java-usages/build.py /path/to/jdtls
|
||||
|
||||
Бінарний файл не відображається.
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plugin>
|
||||
<extension point="org.eclipse.jdt.ls.core.delegateCommandHandler">
|
||||
<delegateCommandHandler class="ai.openidea.usages.CoveredFilesCommand">
|
||||
<command id="openidea.workspace.coveredFiles"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.usages.UsagesCommand">
|
||||
<command id="openidea.java.usages"/>
|
||||
</delegateCommandHandler>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package ai.openidea.usages;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.IProgressMonitor;
|
||||
import org.eclipse.core.runtime.OperationCanceledException;
|
||||
import org.eclipse.jdt.core.*;
|
||||
import org.eclipse.jdt.core.search.IJavaSearchScope;
|
||||
import org.eclipse.jdt.core.search.SearchEngine;
|
||||
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
|
||||
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
|
||||
import org.eclipse.jdt.ls.core.internal.managers.ProjectsManager;
|
||||
import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope;
|
||||
|
||||
/** Candidate files already included in the workspace's semantic source searches. */
|
||||
public final class CoveredFilesCommand implements IDelegateCommandHandler {
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
|
||||
var workspace = ResourcesPlugin.getWorkspace().getRoot();
|
||||
IJavaProject[] projects = JavaCore.create(workspace).getJavaProjects();
|
||||
var setting = JavaLanguageServerPlugin.getPreferencesManager().getPreferences().getSearchScope();
|
||||
var scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects,
|
||||
IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS);
|
||||
List<String> covered = new ArrayList<>();
|
||||
for (Object candidate : (List<?>) arguments.get(0)) {
|
||||
if (monitor.isCanceled()) throw new OperationCanceledException();
|
||||
String uri = (String) candidate;
|
||||
for (var file : workspace.findFilesForLocationURI(URI.create(uri))) {
|
||||
if (!(JavaCore.create(file) instanceof ICompilationUnit unit) || !unit.exists()) continue;
|
||||
IJavaProject project = unit.getJavaProject();
|
||||
// The default project holds standalone open files, not an indexed source tree.
|
||||
if (ProjectsManager.getDefaultProject().equals(project.getProject()) || !project.isOnClasspath(file)) continue;
|
||||
if (scope.encloses(unit)) {
|
||||
covered.add(uri);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return covered;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача