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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 03:53:23 +08:00
1 джерело af19880390
коміт 292ddc4d2a
9 files changed
+270 -6

No files matched your search

+30 -3
Переглянути файл
@@ -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.
+106
Переглянути файл
@@ -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();
+74
Переглянути файл
@@ -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() {
+1 -1
Переглянути файл
@@ -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);
+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.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,
+12 -1
Переглянути файл
@@ -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
Бінарний файл не відображається.
+3
Переглянути файл
@@ -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>
+43
Переглянути файл
@@ -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;
}
}