feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837)

This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-06 21:46:29 +08:00
1 parent ef95cd1716
commit a6d17543fd
26 files changed
+963 -96

No files matched your search

+23 -2
View File
@@ -307,7 +307,11 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
/// References of the symbol at `pos`. `project_wide` also searches project files /// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols /// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`. /// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[allow(clippy::too_many_arguments)]
pub async fn lsp_references( pub async fn lsp_references(
host: &Host, host: &Host,
window: &str, window: &str,
@@ -316,10 +320,24 @@ pub async fn lsp_references(
pos: Pos, pos: Pos,
include_declaration: bool, include_declaration: bool,
project_wide: bool, project_wide: bool,
libraries: bool,
on_partial: super::Channel<References>,
) -> IdeResult<References> { ) -> IdeResult<References> {
let lsp = host.project(window)?.lsp.clone(); let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request); let cancel = host.begin_request(request);
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await; let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel,
Some(&|refs| {
let _ = on_partial.send(refs);
}),
)
.await;
host.end_request(request); host.end_request(request);
result result
} }
@@ -789,6 +807,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
); );
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
d.add("lsp_references", |c| async move { d.add("lsp_references", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_references( lsp_references(
&c.host, &c.host,
&c.window, &c.window,
@@ -797,6 +816,8 @@ pub(crate) fn register(d: &mut Dispatcher) {
c.arg("pos")?, c.arg("pos")?,
c.arg("includeDeclaration")?, c.arg("includeDeclaration")?,
c.arg("projectWide")?, c.arg("projectWide")?,
c.arg("libraries")?,
on_partial,
) )
.await .await
}); });
+12 -1
View File
@@ -303,7 +303,18 @@ impl Smoke {
let (doc, pos) = self.locate(of).await; let (doc, pos) = self.locate(of).await;
let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect(); let want: BTreeSet<String> = files.iter().map(|f| f.to_string()).collect();
self.retry("find usages", self.warm_up, || async { self.retry("find usages", self.warm_up, || async {
let found = ops::lsp::lsp_references(&self.host, WINDOW, RequestId(1), doc, pos, true, true).await; let found = ops::lsp::lsp_references(
&self.host,
WINDOW,
RequestId(1),
doc,
pos,
true,
true,
false,
ops::Channel::new(|_| {}),
)
.await;
match &found { match &found {
Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()), Ok(r) if want.is_subset(&self.files(r.locations.iter().map(|l| l.path.clone()))) => Ok(()),
_ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))), _ => Err(found.map(|r| self.files(r.locations.into_iter().map(|l| l.path)))),
+91 -20
View File
@@ -328,6 +328,9 @@ struct SourceImplementations {
/// Receives the implementations found so far, before a slower search for the rest. /// Receives the implementations found so far, before a slower search for the rest.
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>; type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
/// Receives the references found so far, before a slower search for the rest.
pub type PartialReferences<'a> = Option<&'a (dyn Fn(References) + Send + Sync)>;
/// `first`, then those of `more` not in it (same file and start). /// `first`, then those of `more` not in it (same file and start).
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> { fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect(); let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
@@ -1405,21 +1408,44 @@ impl LspManager {
include_declaration: bool, include_declaration: bool,
project_wide: bool, project_wide: bool,
cancel: CancellationToken, cancel: CancellationToken,
) -> IdeResult<References> {
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
}
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
/// workspace's sources are searched (`libraries_skipped`); with it, those found
/// there arrive on `partial` before the dependencies and the JDK are searched.
#[allow(clippy::too_many_arguments)]
pub async fn references_streaming(
&self,
doc: DocId,
pos: Pos,
include_declaration: bool,
project_wide: bool,
libraries: bool,
cancel: CancellationToken,
partial: PartialReferences<'_>,
) -> IdeResult<References> { ) -> IdeResult<References> {
let snap = self.snapshot(doc)?; let snap = self.snapshot(doc)?;
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
// configuration indexing is reserved for explicit, project-wide usages.
let configuration = if project_wide || snap.language.as_str() != "java" {
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
} else {
None
};
if snap.language.as_str() != "java" if snap.language.as_str() != "java"
&& let Some(result) = configuration && let Some(result) = self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
{ {
return Ok(result); return Ok(result);
} }
let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?; // Scoped probes back code lenses and bulk analysis. Keep their native cost;
// configuration indexing is reserved for explicit, project-wide usages.
let configuration = async {
if project_wide && snap.language.as_str() == "java" {
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await
} else {
Ok(None)
}
};
let native =
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
let (configuration, result) = futures::join!(configuration, native);
let configuration = configuration?;
let mut result = result?;
if let Some(configuration) = configuration { if let Some(configuration) = configuration {
result.locations.extend(configuration.locations); result.locations.extend(configuration.locations);
result.incomplete = result.incomplete.or(configuration.incomplete); result.incomplete = result.incomplete.or(configuration.incomplete);
@@ -1428,13 +1454,16 @@ impl LspManager {
Ok(result) Ok(result)
} }
#[allow(clippy::too_many_arguments)]
async fn native_references( async fn native_references(
&self, &self,
doc: DocId, doc: DocId,
pos: Pos, pos: Pos,
include_declaration: bool, include_declaration: bool,
project_wide: bool, project_wide: bool,
libraries: bool,
cancel: CancellationToken, cancel: CancellationToken,
partial: PartialReferences<'_>,
) -> IdeResult<References> { ) -> IdeResult<References> {
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?; let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
// The extension preserves method identity before JDT collapses references to locations. // The extension preserves method identity before JDT collapses references to locations.
@@ -1446,28 +1475,37 @@ impl LspManager {
let classified = async { let classified = async {
let t = &targets[0]; let t = &targets[0];
let extension_error = Mutex::new(None); let extension_error = Mutex::new(None);
let query = || async { let (snap, cancel, extension_error) = (&snap, &cancel, &extension_error);
// `sources`: the workspace's source folders only, without dependency jars and the JDK.
let query = |sources: bool| async move {
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) { if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
return Err(IdeError::cancelled()); return Err(IdeError::cancelled());
} }
let params = snap.pos_params(t, pos); let params = snap.pos_params(t, pos);
let mut arguments = json!([
params["textDocument"]["uri"],
params["position"]["line"],
params["position"]["character"],
include_declaration
]);
if sources {
arguments.as_array_mut().expect("arguments").push(json!("sources"));
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct JavaReference { struct JavaReference {
#[serde(default)] #[serde(default)]
access: Option<ReferenceAccess>, access: Option<ReferenceAccess>,
location: lsp::Location, location: lsp::Location,
kind: ReferenceKind, kind: ReferenceKind,
#[serde(default)]
text: Option<String>,
} }
let result: IdeResult<Vec<JavaReference>> = self let result: IdeResult<Vec<JavaReference>> = self
.call( .call(
t, t,
"workspace/executeCommand", "workspace/executeCommand",
json!({ json!({ "command": "openidea.java.usages", "arguments": arguments }),
"command": "openidea.java.usages", Some(cancel),
"arguments": [params["textDocument"]["uri"], params["position"]["line"],
params["position"]["character"], include_declaration]
}),
Some(&cancel),
LONG_TIMEOUT, LONG_TIMEOUT,
) )
.await; .await;
@@ -1486,6 +1524,7 @@ impl LspManager {
location, location,
kind: r.kind, kind: r.kind,
access: r.access, access: r.access,
text: r.text,
}) })
}) })
.collect::<Vec<_>>()) .collect::<Vec<_>>())
@@ -1493,10 +1532,42 @@ impl LspManager {
let files = |v: &Vec<ClassifiedReference>| { let files = |v: &Vec<ClassifiedReference>| {
v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect() v.iter().filter(|r| r.location.uri.is_none()).map(|r| r.location.path.to_path_buf()).collect()
}; };
let mut libraries_skipped = false;
let widened = if project_wide { let widened = if project_wide {
self.widened(&snap, t, pos, &cancel, query, files).await? // A library method has thousands of usages in the dependencies and the JDK,
// which take seconds to search: those in the sources (where scope widening
// searches again) are shown first, then the rest are searched once.
let sources = self.widened(snap, t, pos, cancel, || query(true), files).await?;
if let Some(e) = extension_error.lock().take() {
return Err(e);
}
let mut seen = std::collections::HashSet::new();
let found: Vec<_> = sources
.second
.into_iter()
.flatten()
.chain(sources.first)
.filter(|r| seen.insert(r.location.clone()))
.collect();
let all = if !libraries {
libraries_skipped = true;
Vec::new()
} else {
if let Some(partial) = partial
&& !found.is_empty()
{
partial(References {
locations: found.iter().map(|r| r.location.clone()).collect(),
classified: found.clone(),
incomplete: sources.incomplete,
libraries_skipped: false,
});
}
query(false).await?
};
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
} else { } else {
let first = query().await?; let first = query(false).await?;
let empty = first.is_empty(); let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server) scope::Widened::only(first, empty, &t.server)
}; };
@@ -1505,7 +1576,7 @@ impl LspManager {
return Err(IdeError::cancelled()); return Err(IdeError::cancelled());
} }
// Scope widening can retain the first answer after a failed second query. // Scope widening can retain the first answer after a failed second query.
if let Some(e) = extension_error.into_inner() { if let Some(e) = extension_error.lock().take() {
return Err(e); return Err(e);
} }
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
@@ -1519,7 +1590,7 @@ impl LspManager {
.collect(); .collect();
let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect(); let locations: Vec<Location> = classified.iter().map(|r| r.location.clone()).collect();
self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref())); self.note_virtual(&t.server, locations.iter().map(|l| l.uri.as_deref()));
Ok(References { locations, classified, incomplete: widened.incomplete }) Ok(References { locations, classified, incomplete: widened.incomplete, libraries_skipped })
} }
.await; .await;
match classified { match classified {
+8
View File
@@ -380,6 +380,10 @@ pub struct References {
pub classified: Vec<ClassifiedReference>, pub classified: Vec<ClassifiedReference>,
pub locations: Vec<Location>, pub locations: Vec<Location>,
pub incomplete: Option<ReferencesIncomplete>, pub incomplete: Option<ReferencesIncomplete>,
/// The dependencies and the JDK were not searched (asked for without `libraries`).
#[serde(default)]
#[specta(optional)]
pub libraries_skipped: bool,
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] #[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
@@ -503,4 +507,8 @@ pub struct ClassifiedReference {
pub access: Option<ReferenceAccess>, pub access: Option<ReferenceAccess>,
pub location: Location, pub location: Location,
pub kind: ReferenceKind, pub kind: ReferenceKind,
/// The usage's line, when the server has it at hand (a library class's).
#[serde(default)]
#[specta(optional)]
pub text: Option<String>,
} }
+93 -1
View File
@@ -2882,7 +2882,8 @@ async fn classified_java_references_widen_without_losing_metadata() {
assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite)))); assert!(refs.classified.iter().all(|r| matches!(r.access, Some(ReferenceAccess::ReadWrite))));
assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field))); assert!(refs.classified.iter().all(|r| matches!(r.kind, ReferenceKind::Field)));
assert_eq!(refs.incomplete, None); assert_eq!(refs.incomplete, None);
assert_eq!(s.requested("workspace/executeCommand").len(), 3); // Scoped; then the sources, widened; then the dependencies too.
assert_eq!(s.requested("workspace/executeCommand").len(), 4);
assert!(s.requested("textDocument/references").is_empty()); assert!(s.requested("textDocument/references").is_empty());
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await; eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
for limit_hit in [true, false] { for limit_hit in [true, false] {
@@ -2900,6 +2901,97 @@ async fn classified_java_references_widen_without_losing_metadata() {
} }
} }
/// A library method's usages in the sources are shown before the dependencies and the JDK
/// are searched, which takes seconds; scope widening searches the sources alone again.
#[tokio::test(flavor = "multi_thread")]
async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
let mut caps = incremental_caps();
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let s = h.launcher.state("jdtls");
let library = json!({"uri": "jdt://contents/lib.jar/lib/Lib.class", "range": {
"start": {"line": 3, "character": 4}, "end": {"line": 3, "character": 10}}});
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
(method == "workspace/executeCommand").then(|| {
let sources = params["arguments"].get(4) == Some(&json!("sources"));
let mut rows: Vec<Value> = s
.docs
.lock()
.iter()
.flat_map(|(uri, text)| occurrences(uri, text))
.map(|location| json!({"location": location, "kind": "unknown"}))
.chain((!sources).then(|| json!({"location": library, "kind": "unknown", "text": " target();"})))
.collect();
rows.reverse();
Value::Array(rows)
})
}));
let b = h.root.join("b.java");
std::fs::write(&b, "target();").unwrap();
grep_candidates(&h);
let (doc, a) = h.open("a.java", "int target;");
h.wait_status("jdtls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Mutex::new(Vec::new());
let refs = h
.mgr
.references_streaming(
doc,
Pos::new(0, 5),
true,
true,
true,
Default::default(),
Some(&|r| partials.lock().push(r)),
)
.await
.unwrap();
let partials = partials.into_inner();
assert_eq!(partials.len(), 1);
let paths =
|r: &References| r.locations.iter().map(|l| (l.path.to_path_buf(), l.uri.is_some())).collect::<Vec<_>>();
let mut shown = paths(&partials[0]);
shown.sort();
assert_eq!(shown, [(a.clone(), false), (b.clone(), false)]);
assert_eq!(partials[0].classified.len(), 2);
// All of them, those in the sources first.
assert_eq!(refs.locations.len(), 3);
assert!(refs.locations[..2].iter().all(|l| l.uri.is_none()));
assert!(refs.locations[2].uri.is_some());
// The server gives a library class's line along.
let line = |r: &References| r.classified.iter().find(|c| c.location.uri.is_some()).and_then(|c| c.text.clone());
assert_eq!(line(&refs).as_deref(), Some(" target();"));
assert!(!refs.libraries_skipped);
let scopes: Vec<_> =
s.requested("workspace/executeCommand").iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources")), None]);
// Without the libraries: the sources alone, widened, and no partial answer.
let partials = Mutex::new(Vec::new());
let project = h
.mgr
.references_streaming(
doc,
Pos::new(0, 5),
true,
true,
false,
Default::default(),
Some(&|r| partials.lock().push(r)),
)
.await
.unwrap();
assert!(partials.into_inner().is_empty());
assert!(project.libraries_skipped);
assert_eq!(project.locations.len(), 2);
assert!(project.locations.iter().all(|l| l.uri.is_none()));
let scopes: Vec<_> =
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
// Scoped probes (code lenses) search everything at once.
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(scoped.locations.len(), 2);
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn failed_java_command_falls_back_to_unclassified_native_references() { async fn failed_java_command_falls_back_to_unclassified_native_references() {
for project_wide in [false, true] { for project_wide in [false, true] {
+1 -1
View File
@@ -1196,7 +1196,7 @@ mod kotlin_bridge_bundle_tests {
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. /// 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> { pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.6.0.jar"); let extension = data_dir.join("java-usages-1.7.0.jar");
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar"); let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) { if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension); clean_usages_bundles(data_dir, &extension);
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2 Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.6.0 Bundle-Version: 1.7.0
Bundle-RequiredExecutionEnvironment: JavaSE-21 Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime, org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+21 -5
View File
@@ -12,6 +12,14 @@ The Rust bridge logs the failure once per project session. Cancelled requests an
changed source documents remain cancelled and do not start a fallback search. changed source documents remain cancelled and do not start a fallback search.
No global `includeAccessors` preference is changed. No global `includeAccessors` preference is changed.
An optional fifth argument `"sources"` restricts the search to the workspace's
source folders (no dependency jars, no JDK). A library method can have thousands of
usages in those, which take seconds to search. Find Usages searches the sources alone
(Project Files) unless asked for Project and Libraries; then it shows the sources' usages
first and runs the full search after it. Rows in library classes (`jdt:` URIs) carry their
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.
## Search plan and access semantics ## Search plan and access semantics
For source fields on the verified JDT LS implementation, a single plan merges For source fields on the verified JDT LS implementation, a single plan merges
@@ -20,10 +28,14 @@ Targets are deduplicated by JDT element identity before search, retaining native
search scope and ranges. Builder results remain unknown. The optimized native search scope and ranges. Builder results remain unknown. The optimized native
plan is guarded by SHA-256 of ReferencesHandler.class: plan is guarded by SHA-256 of ReferencesHandler.class:
`31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064` `31ff5a10c7d69d58c6a9b619ef17e3ec41b11b545751ba428618a76cc1341064`
(JDT LS 1.63.0.202610191322). Different implementations, non-field selections and (JDT LS 1.63.0.202610191322). On that version, non-field and library/decompiled
library/decompiled selections retain native discovery plus deduplicated category selections resolve their target as `ReferencesHandler.findReferences` does, and every
searches. This conservative fallback preserves coverage when the server changes; search replicates `ReferencesHandler.search` with one difference: a class file whose
it does not promise the same speedup on unverified versions. decompiled source fails to parse loses only its own matches (logged), where the server
throws and fails the whole search after searching most dependencies. Different
implementations retain native discovery plus deduplicated category searches. This
conservative fallback preserves coverage when the server changes; it does not promise
the same speedup or fault isolation on unverified versions.
SearchEngine resolves exact targets. A query-local syntax AST per referenced source SearchEngine resolves exact targets. A query-local syntax AST per referenced source
file supplies expression context without another reference search: simple field file supplies expression context without another reference search: simple field
@@ -57,7 +69,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery ## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.6.0.jar`, and added alongside existing initialization bundles. `java-usages-1.7.0.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles. when replacing the bundle because OSGi caches bundles.
@@ -87,6 +99,10 @@ Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configurat
low-priority background job after import, and builds them under cancellable per-project locks low-priority background job after import, and builds them under cancellable per-project locks
instead of one global lock; its reproducible SHA-256 is instead of one global lock; its reproducible SHA-256 is
`33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`. `33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`.
Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches each class
file's decompiled source on its own and returns the line (`text`) of each usage in a library
class; its reproducible SHA-256 is
`56d35619d5be937b028ac143ba748365a66a74c803bc112e4788a7dce98b44fc`.
```sh ```sh
export PATH=/path/to/jdk25/bin:$PATH export PATH=/path/to/jdk25/bin:$PATH
Binary file not shown.
@@ -4,13 +4,25 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.*; import org.eclipse.jdt.core.*;
import org.eclipse.jdt.core.search.IJavaSearchConstants;
import org.eclipse.jdt.core.search.IJavaSearchScope;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.core.search.SearchMatch;
import org.eclipse.jdt.core.search.SearchParticipant;
import org.eclipse.jdt.core.search.SearchPattern;
import org.eclipse.jdt.core.search.SearchRequestor;
import org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil; import org.eclipse.jdt.internal.corext.codemanipulation.GetterSetterUtil;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JDTUtils; import org.eclipse.jdt.ls.core.internal.JDTUtils;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
import org.eclipse.jdt.ls.core.internal.SearchUtils;
import org.eclipse.jdt.ls.core.internal.handlers.JsonRpcHelpers;
import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler; import org.eclipse.jdt.ls.core.internal.handlers.ReferencesHandler;
import org.eclipse.jdt.ls.core.internal.preferences.Preferences.SearchScope;
import org.eclipse.lsp4j.*; import org.eclipse.lsp4j.*;
/** Classifies search results using JDT method identities, never reference text. */ /** Classifies search results using JDT method identities, never reference text. */
@@ -21,8 +33,14 @@ public final class UsagesCommand implements IDelegateCommandHandler {
int line = ((Number) arguments.get(1)).intValue(); int line = ((Number) arguments.get(1)).intValue();
int column = ((Number) arguments.get(2)).intValue(); int column = ((Number) arguments.get(2)).intValue();
boolean declarations = Boolean.TRUE.equals(arguments.get(3)); boolean declarations = Boolean.TRUE.equals(arguments.get(3));
// "sources": the workspace's source folders only, without dependency jars or the JDK. A
// library method's usages there are found long before the full search ends.
boolean sources = arguments.size() > 4 && "sources".equals(arguments.get(4));
var preferences = JavaLanguageServerPlugin.getPreferencesManager(); var preferences = JavaLanguageServerPlugin.getPreferencesManager();
var handler = new ReferencesHandler(preferences); var handler = new ReferencesHandler(preferences);
// The verified server's search is replicated (see `search`); others keep their own.
boolean replicated = sources || NATIVE_PLAN_SUPPORTED;
Search search = replicated ? (element, locations, m, d) -> search(element, locations, m, d, sources) : handler::search;
var params = new ReferenceParams(new TextDocumentIdentifier(uri), new Position(line, column), new ReferenceContext(declarations)); var params = new ReferenceParams(new TextDocumentIdentifier(uri), new Position(line, column), new ReferenceContext(declarations));
Map<Location, String> results = new LinkedHashMap<>(); Map<Location, String> results = new LinkedHashMap<>();
ITypeRoot root = JDTUtils.resolveTypeRoot(uri); ITypeRoot root = JDTUtils.resolveTypeRoot(uri);
@@ -31,40 +49,95 @@ public final class UsagesCommand implements IDelegateCommandHandler {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
boolean accessors = preferences.getPreferences().isIncludeAccessors(); boolean accessors = preferences.getPreferences().isIncludeAccessors();
SearchPlan plan = new SearchPlan(); SearchPlan plan = new SearchPlan();
// Only replace native target discovery for the exact implementation verified by ITypeRoot targetRoot = null;
// integration tests. New server implementations retain their full native behavior. try {
if (selected instanceof IField field && root instanceof ICompilationUnit && // Only replace native target discovery for the exact implementation verified by
root.getResource() != null && NATIVE_PLAN_SUPPORTED) { // integration tests. New server implementations retain their full native behavior.
plan.add(field, "field", declarations); if (selected instanceof IField field && root instanceof ICompilationUnit &&
if (accessors) { root.getResource() != null && NATIVE_PLAN_SUPPORTED) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
addNativeBuilderTargets(handler, field, plan);
addConfiguredAccessors(field, plan);
}
} else {
// Includes non-fields, library/decompiled roots, and unverified JDT LS builds.
for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown");
if (selected instanceof IField field) {
plan.add(field, "field", declarations); plan.add(field, "field", declarations);
if (accessors) { if (accessors) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false); plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false); plan.add(GetterSetterUtil.getSetter(field), "setter", false);
addNativeBuilderTargets(handler, field, plan);
addConfiguredAccessors(field, plan); addConfiguredAccessors(field, plan);
} }
} else if (replicated) {
// Non-fields and library/decompiled roots: the target ReferencesHandler.findReferences
// resolves (a class file without source through its decompiled working copy), widened
// as it widens a field.
boolean decompiled = preferences.isClientSupportsClassFileContent() && preferences.getPreferences().isIncludeDecompiledSources();
targetRoot = JDTUtils.resolveTypeRoot(uri, decompiled, monitor);
IJavaElement target = targetRoot == null ? null :
JDTUtils.findElementAtSelection(targetRoot, line, column, preferences, monitor);
if (target == null && targetRoot != null) {
target = targetRoot.getElementAt(JsonRpcHelpers.toOffset(targetRoot.getBuffer(), line, column));
}
plan.add(target, target instanceof IField ? "field" : "unknown", declarations);
if (accessors && target instanceof IField field) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
if (NATIVE_PLAN_SUPPORTED) addNativeBuilderTargets(handler, field, plan);
addConfiguredAccessors(field, plan);
}
} else {
// Unverified JDT LS builds.
for (var location : handler.findReferences(params, monitor)) results.put(location, "unknown");
if (selected instanceof IField field) {
plan.add(field, "field", declarations);
if (accessors) {
plan.add(GetterSetterUtil.getGetter(field), "getter", false);
plan.add(GetterSetterUtil.getSetter(field), "setter", false);
addConfiguredAccessors(field, plan);
}
}
} }
} for (var target : plan.targets.values()) {
for (var target : plan.targets.values()) { if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); add(search, target.element, target.kind(), target.declarations, results, monitor);
add(handler, target.element, target.kind(), target.declarations, results, monitor); }
} finally {
if (targetRoot != null) JDTUtils.discardClassFileWorkingCopy(targetRoot);
} }
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException(); if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
List<Map<String, Object>> response = new ArrayList<>(); List<Map<String, Object>> response = new ArrayList<>();
UsageAccess access = new UsageAccess(monitor, results); UsageAccess access = new UsageAccess(monitor, results);
results.forEach((location, kind) -> response.add(Map.of("location", location, "kind", kind, "access", access.classify(location, kind)))); Map<String, String[]> libraryLines = new java.util.HashMap<>();
for (var entry : results.entrySet()) {
if (monitor.isCanceled()) throw new org.eclipse.core.runtime.OperationCanceledException();
Location location = entry.getKey();
Map<String, Object> row = new LinkedHashMap<>();
row.put("location", location);
row.put("kind", entry.getValue());
row.put("access", access.classify(location, entry.getValue()));
String text = libraryLine(location, libraryLines, monitor);
if (text != null) row.put("text", text);
response.add(row);
}
return response; return response;
} }
/**
* The line of a usage in a library class (`jdt:` URI), from the contents the client would
* request for it: so the client need not fetch thousands of class files to show the lines.
*/
private static String libraryLine(Location location, Map<String, String[]> cache, IProgressMonitor monitor) {
String uri = location.getUri();
if (uri == null || !uri.startsWith("jdt:")) return null;
if (!cache.containsKey(uri)) {
String contents = null;
try {
contents = JavaLanguageServerPlugin.getContentProviderManager().getContent(JDTUtils.toURI(uri), monitor);
} catch (RuntimeException e) {
if (e instanceof org.eclipse.core.runtime.OperationCanceledException) throw e;
}
cache.put(uri, contents == null ? null : contents.split("\\r\\n?|\\n", -1));
}
String[] lines = cache.get(uri);
int line = location.getRange().getStart().getLine();
return lines != null && line >= 0 && line < lines.length ? lines[line] : null;
}
/** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities. /** Resolve explicit Lombok @Accessors naming, then search exact JDT method identities.
* Configuration-file-only naming is deliberately not guessed. * Configuration-file-only naming is deliberately not guessed.
*/ */
@@ -191,11 +264,84 @@ public final class UsagesCommand implements IDelegateCommandHandler {
(resolved[0][0].isEmpty() ? resolved[0][1] : resolved[0][0] + "." + resolved[0][1]).equals(owner.getFullyQualifiedName()); (resolved[0][0].isEmpty() ? resolved[0][1] : resolved[0][0] + "." + resolved[0][1]).equals(owner.getFullyQualifiedName());
} }
private static void add(ReferencesHandler handler, IJavaElement element, String kind, boolean declarations, private interface Search {
void run(IJavaElement element, List<Location> locations, IProgressMonitor monitor, boolean declarations) throws Exception;
}
/**
* ReferencesHandler.search of the verified server, except that a class file whose decompiled
* source fails to parse loses only its own matches: there the server throws and fails the
* whole search, after searching most of the dependencies. `sources`: the workspace's source
* folders only, without dependency jars and the JDK.
*/
private static void search(IJavaElement element, List<Location> locations, IProgressMonitor monitor,
boolean declarations, boolean sources) throws CoreException {
SearchPattern pattern = SearchPattern.createPattern(element, IJavaSearchConstants.REFERENCES);
if (pattern == null) return;
if (declarations) {
SearchPattern declaration = SearchPattern.createPattern(element, IJavaSearchConstants.DECLARATIONS);
if (declaration != null) pattern = SearchPattern.createOrPattern(pattern, declaration);
}
var preferences = JavaLanguageServerPlugin.getPreferencesManager();
SearchScope setting = preferences.getPreferences().getSearchScope();
int mask = IJavaSearchScope.SOURCES | IJavaSearchScope.REFERENCED_PROJECTS;
if (!sources && setting != SearchScope.projectOnly) {
mask |= IJavaSearchScope.APPLICATION_LIBRARIES;
if (insideJre(element)) mask |= IJavaSearchScope.SYSTEM_LIBRARIES;
}
IJavaProject[] projects = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects();
IJavaSearchScope scope = SearchEngine.createJavaSearchScope(setting == SearchScope.main, projects, mask);
boolean classFiles = preferences.isClientSupportsClassFileContent();
boolean decompiled = preferences.getPreferences().isIncludeDecompiledSources();
new SearchEngine().search(pattern, new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() }, scope,
new SearchRequestor() {
@Override
public void acceptSearchMatch(SearchMatch match) throws CoreException {
if (match.getAccuracy() == SearchMatch.A_INACCURATE || !(match.getElement() instanceof IJavaElement found)) return;
if (found.getAncestor(IJavaElement.COMPILATION_UNIT) instanceof ICompilationUnit unit) {
locations.add(JDTUtils.toLocation(unit, match.getOffset(), match.getLength()));
return;
}
if (!classFiles || !(found.getAncestor(IJavaElement.CLASS_FILE) instanceof IClassFile classFile)) return;
if (classFile.getSourceRange() != null) {
locations.add(JDTUtils.toLocation(classFile, match.getOffset(), match.getLength()));
return;
}
if (!decompiled) return;
try {
if (found instanceof IMember member) {
Location location = SearchUtils.searchOtherSources(member);
if (location != null) locations.add(location);
}
locations.addAll(JDTUtils.searchDecompiledSources(found, classFile, false, false, monitor));
} catch (org.eclipse.core.runtime.OperationCanceledException e) {
throw e;
} catch (RuntimeException | CoreException e) {
JavaLanguageServerPlugin.logInfo("Usages in decompiled " + classFile.getElementName() + " skipped: " + e);
}
}
}, monitor);
}
/** ReferencesHandler.isInsideJRE: a JDK element is searched in the JDK too. */
private static boolean insideJre(IJavaElement element) {
if (!(element.getAncestor(IJavaElement.PACKAGE_FRAGMENT_ROOT) instanceof IPackageFragmentRoot root)) return true;
try {
IClasspathEntry entry = root.getRawClasspathEntry();
if (entry.getEntryKind() != IClasspathEntry.CPE_CONTAINER) return false;
IClasspathContainer container = JavaCore.getClasspathContainer(entry.getPath(), root.getJavaProject());
return container != null && container.getKind() == IClasspathContainer.K_DEFAULT_SYSTEM;
} catch (JavaModelException e) {
JavaLanguageServerPlugin.logException(e);
return true;
}
}
private static void add(Search search, IJavaElement element, String kind, boolean declarations,
Map<Location, String> results, IProgressMonitor monitor) throws Exception { Map<Location, String> results, IProgressMonitor monitor) throws Exception {
if (element == null) return; if (element == null) return;
List<Location> locations = new ArrayList<>(); List<Location> locations = new ArrayList<>();
handler.search(element, locations, monitor, declarations); search.run(element, locations, monitor, declarations);
for (var location : locations) { for (var location : locations) {
if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; } if (kind.equals("unknown")) { results.putIfAbsent(location, kind); continue; }
String previous = results.get(location); String previous = results.get(location);
+13
View File
@@ -106,6 +106,14 @@ def request(method, params):
if 'error' in msg: raise RuntimeError(msg['error']) if 'error' in msg: raise RuntimeError(msg['error'])
return msg.get('result') return msg.get('result')
raise TimeoutError(method) raise TimeoutError(method)
def assert_sources(line, col, full=None):
"""The "sources" scope finds what the full search finds in the workspace's sources."""
usages = lambda *scope: request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),line,col,False,*scope]})
full = usages() if full is None else full
sources = usages('sources')
key = lambda rows: sorted(json.dumps(r, sort_keys=True) for r in rows)
assert key(sources) == key(r for r in full if r['location']['uri'].startswith('file:')), (line, col, sources, full)
return sources
try: try:
init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, 'import':{'gradle':{'enabled':False}, 'maven':{'enabled':False}}}}}}) init = request('initialize', {'processId':None, 'rootUri':project.as_uri(), 'workspaceFolders':[{'uri':project.as_uri(), 'name':'accessors'}], 'capabilities':{}, 'initializationOptions':{'bundles':[str(bundle)], 'settings':{'java':{'references':{'includeAccessors':True}, 'import':{'gradle':{'enabled':False}, 'maven':{'enabled':False}}}}}})
send({'jsonrpc':'2.0','method':'initialized','params':{}}) send({'jsonrpc':'2.0','method':'initialized','params':{}})
@@ -123,6 +131,11 @@ try:
assert kinds.count('getter') == expected[0], (field, kinds) assert kinds.count('getter') == expected[0], (field, kinds)
assert kinds.count('setter') == expected[1], (field, kinds) assert kinds.count('setter') == expected[1], (field, kinds)
assert kinds.count('field') == expected[2], (field, kinds) assert kinds.count('field') == expected[2], (field, kinds)
assert_sources(line, col, after)
# A JDK method: in the sources, the same as the full search's sources part.
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'
# Respect the existing global preference: the command must not switch it on. # 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}}}}}) 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]}) disabled = request('workspace/executeCommand', {'command':'openidea.java.usages','arguments':[file.as_uri(),3,19,False]})
+10 -1
View File
@@ -136,9 +136,13 @@ pub async fn lsp_resolve_class_file(
/// References of the symbol at `pos`. `project_wide` also searches project files /// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols /// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`. /// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
/// are not searched; with it, those found in the sources arrive on `on_partial` before
/// the slower search for the rest, and the result has them all. Cancel with
/// `cancel_request(request)`.
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn lsp_references( pub async fn lsp_references(
window: Window, window: Window,
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -147,7 +151,10 @@ pub async fn lsp_references(
pos: Pos, pos: Pos,
include_declaration: bool, include_declaration: bool,
project_wide: bool, project_wide: bool,
libraries: bool,
on_partial: tauri::ipc::Channel<References>,
) -> IdeResult<References> { ) -> IdeResult<References> {
let on_partial = super::sender(on_partial);
ide_host::ops::lsp::lsp_references( ide_host::ops::lsp::lsp_references(
&state.host, &state.host,
window.label(), window.label(),
@@ -156,6 +163,8 @@ pub async fn lsp_references(
pos, pos,
include_declaration, include_declaration,
project_wide, project_wide,
libraries,
on_partial,
) )
.await .await
} }
+9 -2
View File
@@ -424,9 +424,12 @@ export const commands = {
/** /**
* References of the symbol at `pos`. `project_wide` also searches project files * References of the symbol at `pos`. `project_wide` also searches project files
* outside the language server's analysis scope; bulk scans over many symbols * outside the language server's analysis scope; bulk scans over many symbols
* leave it off. Cancel with `cancel_request(request)`. * leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
* are not searched; with it, those found in the sources arrive on `on_partial` before
* the slower search for the rest, and the result has them all. Cancel with
* `cancel_request(request)`.
*/ */
lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })), lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean, libraries: boolean, onPartial: Channel<References>) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide, libraries, onPartial })),
lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError<Highlight[], IdeError>(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })), lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError<Highlight[], IdeError>(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })),
lspWorkspaceSymbols: (request: RequestId, query: string) => typedError<WorkspaceSymbol[], IdeError>(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })), lspWorkspaceSymbols: (request: RequestId, query: string) => typedError<WorkspaceSymbol[], IdeError>(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })),
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })), lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })),
@@ -2113,6 +2116,8 @@ export type ClassifiedReference = {
access?: ReferenceAccess | null, access?: ReferenceAccess | null,
location: Location, location: Location,
kind: ReferenceKind, kind: ReferenceKind,
/** The usage's line, when the server has it at hand (a library class's). */
text?: string | null,
}; };
export type ClearCachesResult = { export type ClearCachesResult = {
@@ -6178,6 +6183,8 @@ export type References = {
classified?: ClassifiedReference[], classified?: ClassifiedReference[],
locations: Location[], locations: Location[],
incomplete: ReferencesIncomplete | null, incomplete: ReferencesIncomplete | null,
/** The dependencies and the JDK were not searched (asked for without `libraries`). */
librariesSkipped?: boolean,
}; };
/** Why a references search may have missed usages. */ /** Why a references search may have missed usages. */
+15 -1
View File
@@ -15,6 +15,12 @@ vi.mock("@/platform/ipc", async (orig) => {
}); });
const executeAction = vi.hoisted(() => vi.fn(async () => true)); const executeAction = vi.hoisted(() => vi.fn(async () => true));
vi.mock("@/platform/actions", () => ({ executeAction })); vi.mock("@/platform/actions", () => ({ executeAction }));
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
const { lensTargets, usagesLensExtension } = await import("./codeLens"); const { lensTargets, usagesLensExtension } = await import("./codeLens");
const { lspDoc } = await import("./lsp"); const { lspDoc } = await import("./lsp");
@@ -89,7 +95,15 @@ describe("background usage counts", () => {
it("uses the server's existing scope and leaves full searches to the explicit action", async () => { it("uses the server's existing scope and leaves full searches to the explicit action", async () => {
const view = editor(); const view = editor();
await settle(); await settle();
expect(ipc.lspReferences).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 0 }, false, false); expect(ipc.lspReferences).toHaveBeenCalledWith(
expect.any(Number),
7,
{ line: 0, col: 0 },
false,
false,
false,
expect.anything(),
);
expect(titles(view)).toEqual(["2 usages"]); expect(titles(view)).toEqual(["2 usages"]);
view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click")); view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click"));
expect(executeAction).toHaveBeenCalledWith("find.showUsages"); expect(executeAction).toHaveBeenCalledWith("find.showUsages");
+10 -1
View File
@@ -5,6 +5,7 @@
* and symbol, and at most a few reference requests in flight. Beside each * and symbol, and at most a few reference requests in flight. Beside each
* count, the declaration's last author from VCS blame (`authorLens`). * count, the declaration's last author from VCS blame (`authorLens`).
*/ */
import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, type CodeLens } from "@/editor/api"; import { codeLenses, type CodeLens } from "@/editor/api";
@@ -195,7 +196,15 @@ class UsagesLensPlugin {
this.pending.add(request); this.pending.add(request);
// Opening candidate files for each count triggers expensive server analysis. // Opening candidate files for each count triggers expensive server analysis.
// Only an explicit Find Usages should widen the server's scope. // Only an explicit Find Usages should widen the server's scope.
return commands.lspReferences(request, doc.doc, { line: t.line, col: t.col }, false, false); return commands.lspReferences(
request,
doc.doc,
{ line: t.line, col: t.col },
false,
false,
false,
new Channel(),
);
}, wanted); }, wanted);
if (wanted()) { if (wanted()) {
if (incomplete) this.incomplete = true; if (incomplete) this.incomplete = true;
+6
View File
@@ -65,6 +65,12 @@ const showHint = vi.fn();
vi.mock("@/editor/lsp/hint", () => ({ showHint })); vi.mock("@/editor/lsp/hint", () => ({ showHint }));
const executeAction = vi.fn(); const executeAction = vi.fn();
vi.mock("@/platform/actions", () => ({ executeAction })); vi.mock("@/platform/actions", () => ({ executeAction }));
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
let answer = "cancel"; let answer = "cancel";
const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false })); const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false }));
let confirmAnswer: { checked: boolean } | null = { checked: false }; let confirmAnswer: { checked: boolean } | null = { checked: false };
+11 -2
View File
@@ -10,6 +10,7 @@
* Files (project view): references to the file's top-level symbols from other * Files (project view): references to the file's top-level symbols from other
* files, when the language server can answer (`fileUsages`). * files, when the language server can answer (`fileUsages`).
*/ */
import { Channel } from "@tauri-apps/api/core";
import type { EditorView } from "@codemirror/view"; import type { EditorView } from "@codemirror/view";
import type { Text } from "@codemirror/state"; import type { Text } from "@codemirror/state";
import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings"; import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings";
@@ -254,7 +255,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom
if (target.local) if (target.local)
return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range })); return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
const { locations: refs } = await lspRequest(() => const { locations: refs } = await lspRequest(() =>
commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true), commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true, false, new Channel()),
); );
return refs.filter((l) => outside(l.path, l.range)); return refs.filter((l) => outside(l.path, l.range));
} }
@@ -554,7 +555,15 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal)
// Every symbol of every file: the server's own scope (a project search each would be too slow). // Every symbol of every file: the server's own scope (a project search each would be too slow).
refs = ( refs = (
await lspRequest(() => await lspRequest(() =>
commands.lspReferences(nextRequestId(), doc, item.selection_range.start, false, false), commands.lspReferences(
nextRequestId(),
doc,
item.selection_range.start,
false,
false,
false,
new Channel(),
),
) )
).locations; ).locations;
} catch (e) { } catch (e) {
@@ -15,6 +15,16 @@
font-size: var(--fs-small); font-size: var(--fs-small);
color: var(--fg-muted); color: var(--fg-muted);
} }
.scope {
flex: none;
}
.searching {
flex: none;
display: flex;
align-items: center;
}
.title { .title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+22 -2
View File
@@ -11,15 +11,17 @@ import { Decoration, EditorView } from "@codemirror/view";
import { codePreviewExtensions } from "@/editor/api"; import { codePreviewExtensions } from "@/editor/api";
import { basename } from "@/platform/project"; import { basename } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap"; import { shortcutLabel } from "@/platform/keymap";
import { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui"; import { Icon, IconButton, List, Popup, Select, Spinner, fileIconName, listNavigate } from "@/ui";
import { openLocation, readFileLines } from "./ipc"; import { openLocation, readFileLines } from "./ipc";
import { import {
dismissUsagesPopup, dismissUsagesPopup,
setUsagesPopupScope,
showUsageResults, showUsageResults,
usageCount, usageCount,
useUsagesPopup, useUsagesPopup,
type Usage, type Usage,
type UsageGroup, type UsageGroup,
type UsageScope,
} from "./usages"; } from "./usages";
import { UsagePreview } from "./UsagesView"; import { UsagePreview } from "./UsagesView";
import styles from "./UsagesPopup.module.css"; import styles from "./UsagesPopup.module.css";
@@ -79,7 +81,7 @@ export function UsagesPopup() {
const ROW = 24; const ROW = 24;
function UsagesPopupInner() { function UsagesPopupInner() {
const { anchor, symbol, groups, note } = useUsagesPopup(); const { anchor, symbol, groups, note, loading, scope } = useUsagesPopup();
const preview = useUsagesPopupPreview((s) => s.preview); const preview = useUsagesPopupPreview((s) => s.preview);
const all = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]); const all = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
@@ -152,6 +154,24 @@ function UsagesPopupInner() {
{filter || items.length !== total ? tx`${items.length} of ${total} found` : tx`${total} found`} {filter || items.length !== total ? tx`${items.length} of ${total} found` : tx`${total} found`}
</span> </span>
</span> </span>
{scope && (
<Select<UsageScope>
variant="ghost"
aria-label={tx("Scope")}
className={styles.scope}
value={scope}
options={[
{ value: "project", label: tx("Project Files") },
{ value: "libraries", label: tx("Project and Libraries") },
]}
onChange={setUsagesPopupScope}
/>
)}
{loading && (
<span className={styles.searching} role="status" aria-label={tx("Searching for more usages")}>
<Spinner size={12} />
</span>
)}
{filter && ( {filter && (
<span className={clsx(styles.filter, !items.length && styles.noMatch)} role="status"> <span className={clsx(styles.filter, !items.length && styles.noMatch)} role="status">
{filter} {filter}
@@ -41,6 +41,19 @@
.error { .error {
color: var(--danger); color: var(--danger);
} }
.link {
flex: none;
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--link);
cursor: pointer;
}
.link:hover {
color: var(--link-hover);
text-decoration: underline;
}
.body { .body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+20 -2
View File
@@ -66,8 +66,16 @@ export function UsagesTabs() {
const tabs = useUsages((s) => s.tabs); const tabs = useUsages((s) => s.tabs);
const search = useUsages(activeUsageSearch); const search = useUsages(activeUsageSearch);
if (!search) return null; if (!search) return null;
const label = (t: (typeof tabs)[number]) => const label = (t: (typeof tabs)[number]) => {
t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages"); if (!t.symbol) return tx("Usages");
const text =
t.scope === "project"
? tx("Usages of '{symbol}' in Project Files")
: t.scope === "libraries"
? tx("Usages of '{symbol}' in Project and Libraries")
: tx("Usages of '{symbol}'");
return formatMessage(text, { symbol: t.symbol });
};
return ( return (
<Tabs <Tabs
variant="compact" variant="compact"
@@ -268,6 +276,16 @@ export function UsagesView() {
</div> </div>
<div className={styles.body} onKeyDown={onKeyDown}> <div className={styles.body} onKeyDown={onKeyDown}>
<UsageAccessFilter items={allUsages} /> <UsageAccessFilter items={allUsages} />
{!search.loading && !search.error && search.scope === "project" && (
<div className={styles.summary}>
<T>Usages in Project Files</T>
{search.searchLibraries && (
<button type="button" className={styles.link} onClick={() => search.searchLibraries?.()}>
{tx("Search in Project and Libraries")}
</button>
)}
</div>
)}
{(search.loading || search.error) && ( {(search.loading || search.error) && (
<div className={styles.summary} role="status" aria-live="polite"> <div className={styles.summary} role="status" aria-live="polite">
{search.loading ? ( {search.loading ? (
@@ -23,6 +23,12 @@ vi.mock("@/platform/ipc", async (orig) => {
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } }; return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
}); });
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() })); vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
},
}));
const { _addEditor } = await import("@/editor/api"); const { _addEditor } = await import("@/editor/api");
const { fetchUsages } = await import("./usages"); const { fetchUsages } = await import("./usages");
@@ -42,7 +48,7 @@ beforeEach(() => {
describe("Find Usages fallback", () => { describe("Find Usages fallback", () => {
it("uses the server when it answers, even with nothing", async () => { it("uses the server when it answers, even with nothing", async () => {
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null })); cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null }));
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null }); expect(await fetchUsages(caret)).toEqual({ groups: [], note: null, librariesSkipped: false });
expect(cmd.syntaxLocals).not.toHaveBeenCalled(); expect(cmd.syntaxLocals).not.toHaveBeenCalled();
}); });
@@ -50,7 +56,7 @@ describe("Find Usages fallback", () => {
const usage = { path: "/p/a.ts", range: r(1, 0, 1) }; const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" })); cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" }));
const res = await fetchUsages(caret, 42 as never); const res = await fetchUsages(caret, 42 as never);
expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true); expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true, false, expect.anything());
expect(res.groups[0]!.usages.length).toBe(1); expect(res.groups[0]!.usages.length).toBe(1);
expect(res.note).toBe(incompleteNote("indexing", "x")); expect(res.note).toBe(incompleteNote("indexing", "x"));
expect(incompleteNote("tooManyFiles", "x")).toBe( expect(incompleteNote("tooManyFiles", "x")).toBe(
+77 -2
View File
@@ -5,12 +5,33 @@ vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()), ...(await orig<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show }), toolWindowService: () => ({ show }),
})); }));
const referenceResponse = vi.fn();
const lspReferences = vi.fn(async (..._args: unknown[]) => ({ status: "ok", data: null }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, commands: { ...actual.commands, lspReferences } };
});
vi.mock("./ipc", async (orig) => ({ vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()), ...(await orig<typeof import("./ipc")>()),
readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]), caretContext: () => ({ doc: 1, path: "/p/a.rs", line: 0, col: 0, word: "used", view: null }),
lspRequest: (request: () => unknown) => {
request();
return referenceResponse();
},
readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();", " used();"]]]),
}));
const channels: { onmessage: (refs: unknown) => void }[] = [];
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
constructor() {
channels.push(this);
}
},
})); }));
const { activeUsageSearch, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } = const { activeUsageSearch, findUsages, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } =
await import("./usages"); await import("./usages");
describe("showUsageResults", () => { describe("showUsageResults", () => {
@@ -25,6 +46,19 @@ describe("showUsageResults", () => {
]); ]);
}); });
it("shows the lines the server gave along for files it does not read", async () => {
const lib = {
path: "jdt://contents/lib/Lib.class",
range: { start: { line: 3, col: 4 }, end: { line: 3, col: 8 } },
};
await showUsageResults({
symbol: "used",
locations: [lib],
classified: [{ location: lib, kind: "unknown", text: " used();" }],
});
expect(activeUsageSearch(useUsages.getState())!.groups[0]!.usages[0]!.text).toBe(" used();");
});
it("ignores malformed arguments", async () => { it("ignores malformed arguments", async () => {
show.mockClear(); show.mockClear();
await showUsageResults({ symbol: "x" }); await showUsageResults({ symbol: "x" });
@@ -32,6 +66,47 @@ describe("showUsageResults", () => {
}); });
}); });
describe("findUsages", () => {
it("searches Project Files, then Project and Libraries on request", async () => {
const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
lspReferences.mockClear();
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null, librariesSkipped: true });
await findUsages();
const project = activeUsageSearch(useUsages.getState())!;
expect(project).toMatchObject({ loading: false, scope: "project" });
expect(lspReferences.mock.lastCall![5]).toBe(false);
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
project.searchLibraries!();
await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.loading).toBe(false));
expect(lspReferences.mock.lastCall![5]).toBe(true);
const all = activeUsageSearch(useUsages.getState())!;
expect(all.scope).toBe("libraries");
// Rerun keeps the scope.
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
all.rerun!();
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(3));
expect(lspReferences.mock.lastCall![5]).toBe(true);
resetUsages();
});
it("shows the usages found first while it searches for the rest", async () => {
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
let resolve!: (refs: unknown) => void;
referenceResponse.mockReturnValueOnce(new Promise((r) => (resolve = r)));
const search = findUsages();
await vi.waitFor(() => expect(channels).not.toHaveLength(0));
channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null });
await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.groups).toHaveLength(1));
expect(activeUsageSearch(useUsages.getState())).toMatchObject({ loading: true, error: null });
resolve({ locations: [at(1), at(2)], incomplete: null });
await search;
const done = activeUsageSearch(useUsages.getState())!;
expect(done.loading).toBe(false);
expect(done.groups[0]!.usages.map((u) => u.line)).toEqual([1, 2]);
resetUsages();
});
});
describe("usage tabs", () => { describe("usage tabs", () => {
const search = (id: number) => ({ const search = (id: number) => ({
id, id,
+159 -25
View File
@@ -1,9 +1,11 @@
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */ /** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
import { create } from "zustand"; import { create } from "zustand";
import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, RequestId } from "@/bindings"; import { Channel } from "@tauri-apps/api/core";
import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, References, RequestId } from "@/bindings";
import { clearLineTokens } from "@/editor/lineTokens"; import { clearLineTokens } from "@/editor/lineTokens";
import { incompleteNote } from "@/editor/lsp/lsp"; import { incompleteNote } from "@/editor/lsp/lsp";
import { commands, isCancelled, nextRequestId } from "@/platform/ipc"; import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { toolWindowService } from "@/workbench/contributions"; import { toolWindowService } from "@/workbench/contributions";
import { import {
caretContext, caretContext,
@@ -49,7 +51,7 @@ export function groupUsages(
const key = usageLocationKey(l); const key = usageLocationKey(l);
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const text = files.get(l.path)?.[l.range.start.line] ?? ""; const text = files.get(l.path)?.[l.range.start.line] ?? kinds.get(key)?.text ?? "";
const endCol = l.range.end.line === l.range.start.line ? l.range.end.col : Math.max(text.length, l.range.start.col); const endCol = l.range.end.line === l.range.start.line ? l.range.end.col : Math.max(text.length, l.range.start.col);
let list = byPath.get(l.path); let list = byPath.get(l.path);
if (!list) byPath.set(l.path, (list = [])); if (!list) byPath.set(l.path, (list = []));
@@ -86,6 +88,15 @@ export function trimPreview(u: Usage): { text: string; textCol: number; from: nu
return { text, textCol: lead, from, to }; return { text, textCol: lead, from, to };
} }
/** Project Files, or Project and Libraries (Java's dependency jars and JDK too). */
export type UsageScope = "project" | "libraries";
/** The scope a search ran in, when there was a choice (`null` when the server has none). */
function searchedScope(libraries: boolean, librariesSkipped: boolean, widenable: boolean): UsageScope | null {
if (librariesSkipped) return "project";
return libraries && widenable ? "libraries" : null;
}
export interface UsageSearch { export interface UsageSearch {
/** Identifies one search run (UI state such as expansion resets per run). */ /** Identifies one search run (UI state such as expansion resets per run). */
id: number; id: number;
@@ -97,6 +108,10 @@ export interface UsageSearch {
note: string | null; note: string | null;
/** Re-runs the same query (rerun button). */ /** Re-runs the same query (rerun button). */
rerun: (() => void) | null; rerun: (() => void) | null;
/** Where it searched, when the server can search beyond Project Files (Java's jars and JDK). */
scope?: UsageScope | null;
/** Runs it again in Project and Libraries. */
searchLibraries?: (() => void) | null;
/** Kept when a new search runs (Pin Tab). */ /** Kept when a new search runs (Pin Tab). */
pinned?: boolean; pinned?: boolean;
} }
@@ -163,6 +178,10 @@ interface UsagesPopupState {
symbol: string | null; symbol: string | null;
groups: UsageGroup[]; groups: UsageGroup[];
note: string | null; note: string | null;
/** Shows those found so far while the search goes on. */
loading: boolean;
/** Where it searched, when the server can search beyond Project Files. */
scope: UsageScope | null;
} }
export const useUsagesPopup = create<UsagesPopupState>(() => ({ export const useUsagesPopup = create<UsagesPopupState>(() => ({
@@ -171,31 +190,64 @@ export const useUsagesPopup = create<UsagesPopupState>(() => ({
symbol: null, symbol: null,
groups: [], groups: [],
note: null, note: null,
loading: false,
scope: null,
})); }));
interface FetchedUsages { interface FetchedUsages {
groups: UsageGroup[]; groups: UsageGroup[];
note: string | null; note: string | null;
librariesSkipped: boolean;
}
/** Paths whose lines are read to show these usages: not those the server gave along. */
function pathsToRead(locs: readonly Location[], classified: readonly ClassifiedReference[]): string[] {
const given = new Set(classified.filter((r) => r.text != null).map((r) => usageLocationKey(r.location)));
return locs.filter((l) => !given.has(usageLocationKey(l))).map((l) => l.path);
} }
/** /**
* The server's references across the project (files outside its analysis scope * The server's references across the project (files outside its analysis scope
* included); when it is not ready or fails, the syntax fallback (an empty server * included); when it is not ready or fails, the syntax fallback (an empty server
* answer is respected). Without a fallback the server error stands. * answer is respected). Without a fallback the server error stands. Without
* `libraries`, Java's dependencies and JDK are not searched (Project Files); with it,
* those found in the sources go to `onPartial` before the slower search for the rest.
* Cancel with `commands.cancelRequest(request)`. * Cancel with `commands.cancelRequest(request)`.
*/ */
export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise<FetchedUsages> { export async function fetchUsages(
c: CaretContext,
request: RequestId = nextRequestId(),
onPartial?: (found: FetchedUsages) => void,
libraries = false,
): Promise<FetchedUsages> {
// Usage lines are read afresh: so are the files their syntax colours come from. // Usage lines are read afresh: so are the files their syntax colours come from.
clearLineTokens(); clearLineTokens();
let locs: Location[]; let locs: Location[];
let classified: ClassifiedReference[] = []; let classified: ClassifiedReference[] = [];
let note: string | null = null; let note: string | null = null;
let librariesSkipped = false;
let settled = false;
const channel = new Channel<References>();
channel.onmessage = (refs) => {
if (!onPartial) return;
void readManyFileLines(pathsToRead(refs.locations, refs.classified ?? [])).then((files) => {
if (settled) return;
onPartial({
groups: groupUsages(refs.locations, files, refs.classified ?? []),
note: refs.incomplete ? incompleteNote(refs.incomplete, c.word) : null,
librariesSkipped: false,
});
});
};
try { try {
const refs = await lspRequest(() => const refs = await lspRequest(() =>
commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true), commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true, libraries, channel),
); ).finally(() => {
settled = true;
});
locs = refs.locations; locs = refs.locations;
classified = refs.classified ?? []; classified = refs.classified ?? [];
librariesSkipped = refs.librariesSkipped ?? false;
if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word); if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word);
} catch (e) { } catch (e) {
if (isCancelled(e)) throw e; if (isCancelled(e)) throw e;
@@ -210,8 +262,8 @@ export async function fetchUsages(c: CaretContext, request: RequestId = nextRequ
locs = fb.locs; locs = fb.locs;
note = fb.note; note = fb.note;
} }
const files = await readManyFileLines(locs.map((l) => l.path)); const files = await readManyFileLines(pathsToRead(locs, classified));
return { groups: groupUsages(locs, files, classified), note }; return { groups: groupUsages(locs, files, classified), note, librariesSkipped };
} }
/** In-flight server requests: of tool window searches by search id, and of the popup. */ /** In-flight server requests: of tool window searches by search id, and of the popup. */
@@ -231,7 +283,8 @@ export function resetUsages() {
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
popupRequest = null; popupRequest = null;
useUsages.setState({ tabs: [], active: null }); useUsages.setState({ tabs: [], active: null });
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null }); useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null, loading: false, scope: null });
popupTarget = null;
} }
export async function findUsages() { export async function findUsages() {
@@ -240,9 +293,15 @@ export async function findUsages() {
hint("Open a file to find usages"); hint("Open a file to find usages");
return; return;
} }
const run = async (prev: number | null) => { // `widenable`: an earlier run of this search skipped the libraries.
const run = async (prev: number | null, libraries: boolean, widenable: boolean) => {
const id = ++searchSeq; const id = ++searchSeq;
const base = { id, symbol: c.word, rerun: () => void run(id) }; const base = {
id,
symbol: c.word,
rerun: () => void run(id, libraries, widenable),
searchLibraries: () => void run(id, true, true),
};
const loading = { ...base, loading: true, groups: [], error: null, note: null }; const loading = { ...base, loading: true, groups: [], error: null, note: null };
if (prev !== null) cancelSearch(prev); if (prev !== null) cancelSearch(prev);
if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading); if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading);
@@ -250,8 +309,16 @@ export async function findUsages() {
const request = nextRequestId(); const request = nextRequestId();
inflight.set(id, request); inflight.set(id, request);
try { try {
const { groups, note } = await fetchUsages(c, request); const { groups, note, librariesSkipped } = await fetchUsages(
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note }); c,
request,
(found) => {
if (inflight.get(id) === request) replaceUsageTab(id, { ...loading, groups: found.groups, note: found.note });
},
libraries,
);
const scope = searchedScope(libraries, librariesSkipped, widenable);
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note, scope });
} catch (e) { } catch (e) {
// Cancelled here (closed tab, rerun): a newer state already replaced it. // Cancelled here (closed tab, rerun): a newer state already replaced it.
if (isCancelled(e) && !inflight.has(id)) return; if (isCancelled(e) && !inflight.has(id)) return;
@@ -262,7 +329,7 @@ export async function findUsages() {
if (inflight.get(id) === request) inflight.delete(id); if (inflight.get(id) === request) inflight.delete(id);
} }
}; };
await run(null); await run(null, false, false);
} }
/** /**
@@ -300,34 +367,101 @@ export function dismissUsagesPopup() {
useUsagesPopup.setState({ open: false }); useUsagesPopup.setState({ open: false });
} }
/** How long Show Usages waits for all usages before it shows those found first. */
const EARLY_POPUP_DELAY_MS = 300;
/** How long the hint after Show Usages stays (and invoking it again widens its search). */
const HINT_MS = 4000;
/**
* What the popup, or the hint after it, shows usages of: invoked again meanwhile, Show
* Usages searches it in Project and Libraries, wherever the caret is now.
*/
let popupTarget: { c: CaretContext; widenable: boolean; hintUntil: number } | null = null;
export async function showUsagesPopup() { export async function showUsagesPopup() {
const target = popupTarget;
const popup = useUsagesPopup.getState();
if (target?.widenable && ((popup.open && popup.scope === "project") || Date.now() < target.hintUntil)) {
await searchPopup(target.c, true, true);
return;
}
const c = caretContext(); const c = caretContext();
if (!c) { if (!c) {
hint("Open a file to show usages"); hint("Open a file to show usages");
return; return;
} }
await searchPopup(c, false, false);
}
/** The popup's scope chooser: searches its symbol again in `scope`. */
export function setUsagesPopupScope(scope: UsageScope) {
const target = popupTarget;
if (!target || scope === useUsagesPopup.getState().scope) return;
void searchPopup(target.c, scope === "libraries", target.widenable);
}
async function searchPopup(c: CaretContext, libraries: boolean, widenable: boolean) {
// Set from the start: the scope chooser can switch while this search goes on.
popupTarget = { c, widenable, hintUntil: 0 };
const reopened = useUsagesPopup.getState().open;
if (reopened) useUsagesPopup.setState({ loading: true, scope: libraries ? "libraries" : "project" });
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {}); if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
const request = nextRequestId(); const request = nextRequestId();
popupRequest = request; popupRequest = request;
// Opened with those found first when the rest take a while; it then stays open, even
// when they were all. A quick search still jumps to a single usage.
let early = false;
let found: FetchedUsages | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const openEarly = () => {
if (popupRequest !== request || !found) return;
if (!early && !reopened) useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word });
early = true;
useUsagesPopup.setState({ ...found, loading: true, scope: widenable ? "libraries" : null });
};
const again = shortcutLabel("find.showUsages");
try { try {
const { groups, note } = await fetchUsages(c, request); const { groups, note, librariesSkipped } = await fetchUsages(
c,
request,
(partial) => {
if (!usageCount(partial.groups)) return;
found = partial;
if (early) openEarly();
else timer ??= setTimeout(openEarly, EARLY_POPUP_DELAY_MS);
},
libraries,
).finally(() => clearTimeout(timer));
if (popupRequest !== request) return; if (popupRequest !== request) return;
const scope = searchedScope(libraries, librariesSkipped, widenable);
popupTarget = { c, widenable: widenable || scope !== null, hintUntil: 0 };
const total = usageCount(groups); const total = usageCount(groups);
if (early || reopened || total > 1) {
if (!early && !reopened) useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word });
useUsagesPopup.setState({ groups, note, loading: false, scope });
return;
}
// As in IDEA, the hint offers to widen the search, which Show Usages does while it shows.
const widen = scope === "project" && again !== "";
if (widen) popupTarget.hintUntil = Date.now() + HINT_MS;
if (!total) { if (!total) {
const none = c.word ? `No usages of '${c.word}' found` : "No usages found"; if (widen) hint(`No usages found in Project Files. Press ${again} again to search in 'Project and Libraries'`);
hint(note ? `${none}. ${note}` : none); else if (scope === "project") hint("No usages found in Project Files");
else {
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
hint(note ? `${none}. ${note}` : none);
}
return; return;
} }
if (total === 1) { // IDEA jumps to the only usage and says so in the target editor.
// IDEA jumps to the only usage and says so in the target editor. const u = groups[0]!.usages[0]!;
const u = groups[0]!.usages[0]!; await openLocation(u.path, u.line, u.col);
await openLocation(u.path, u.line, u.col); if (widen) hint(`Only one usage in Project Files. Press ${again} again to search in 'Project and Libraries'`);
hint("Only one usage in Project Files"); else if (scope === "libraries") hint("Only one usage in Project and Libraries");
return; else hint("Only one usage in Project Files");
}
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
} catch (e) { } catch (e) {
if (popupRequest !== request || isCancelled(e)) return; if (popupRequest !== request || isCancelled(e)) return;
if (early || reopened) useUsagesPopup.setState({ loading: false });
reportError(e); reportError(e);
} finally { } finally {
if (popupRequest === request) popupRequest = null; if (popupRequest === request) popupRequest = null;
+152 -3
View File
@@ -2,16 +2,44 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react"; import { act, fireEvent, render, screen } from "@testing-library/react";
const calls: string[] = []; const calls: string[] = [];
const referenceResponse = vi.fn(async () => ({ locations: [loc], incomplete: null })); const referenceResponse = vi.fn(
async (): Promise<{ locations: (typeof loc)[]; incomplete: null; librariesSkipped?: boolean }> => ({
locations: [loc],
incomplete: null,
}),
);
const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } }; const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
const lspReferences = vi.fn(async () => ({ status: "ok", data: null }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, commands: { ...actual.commands, lspReferences } };
});
vi.mock("@/platform/keymap", async (orig) => ({
...(await orig<typeof import("@/platform/keymap")>()),
shortcutLabel: (id: string) => (id === "find.showUsages" ? "⌥⌘F7" : ""),
}));
vi.mock("./ipc", async (orig) => ({ vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()), ...(await orig<typeof import("./ipc")>()),
caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }), caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }),
lspRequest: () => referenceResponse(), lspRequest: (request: () => unknown) => {
request();
return referenceResponse();
},
readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]), readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]),
openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`), openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`),
hint: (text: string) => void calls.push(`hint ${text}`), hint: (text: string) => void calls.push(`hint ${text}`),
})); }));
/** The partial-results channels of the searches, newest last. */
const channels: { onmessage: (refs: unknown) => void }[] = [];
vi.mock("@tauri-apps/api/core", async (orig) => ({
...(await orig<object>()),
Channel: class {
onmessage = () => {};
constructor() {
channels.push(this);
}
},
}));
const showUsageResults = vi.fn(); const showUsageResults = vi.fn();
vi.mock("./usages", async (orig) => ({ vi.mock("./usages", async (orig) => ({
...(await orig<typeof import("./usages")>()), ...(await orig<typeof import("./usages")>()),
@@ -19,7 +47,7 @@ vi.mock("./usages", async (orig) => ({
})); }));
const { UsagesPopup, filterUsages, usageLocations } = await import("./UsagesPopup"); const { UsagesPopup, filterUsages, usageLocations } = await import("./UsagesPopup");
const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup } = await import("./usages"); const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup, setUsagesPopupScope } = await import("./usages");
const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text }); const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text });
const groups = [ const groups = [
@@ -198,6 +226,127 @@ it("keeps filter controls available when all classified rows are hidden", () =>
expect(screen.getAllByRole("option")).toHaveLength(2); expect(screen.getAllByRole("option")).toHaveLength(2);
}); });
it("opens with the usages found first and fills in the rest", async () => {
render(<UsagesPopup />);
const at = (line: number) => ({ ...loc, range: { start: { line, col: 4 }, end: { line, col: 8 } } });
let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
referenceResponse.mockReturnValueOnce(
new Promise((r) => {
resolve = r;
}),
);
const before = calls.length;
const pending = showUsagesPopup();
await act(async () => channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null }));
// Not before the rest had a moment to arrive.
expect(useUsagesPopup.getState().open).toBe(false);
await act(() => new Promise((r) => setTimeout(r, 350)));
expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: true });
expect(screen.getByRole("status", { name: "Searching for more usages" })).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(1);
await act(async () => channels.at(-1)!.onmessage({ locations: [at(1), at(2)], incomplete: null }));
expect(screen.getAllByRole("option")).toHaveLength(2);
await act(async () => {
resolve({ locations: [at(1), at(2), at(3)], incomplete: null });
await pending;
});
expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: false });
expect(screen.queryByRole("status", { name: "Searching for more usages" })).toBeNull();
expect(screen.getAllByRole("option")).toHaveLength(3);
expect(calls.slice(before)).toEqual([]);
act(() => dismissUsagesPopup());
});
it("jumps to a single usage when all arrive quickly, and otherwise stays open", async () => {
render(<UsagesPopup />);
let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
const respond = () =>
referenceResponse.mockReturnValueOnce(
new Promise((r) => {
resolve = r;
}),
);
respond();
let before = calls.length;
let pending = showUsagesPopup();
await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null }));
await act(async () => {
resolve({ locations: [loc], incomplete: null });
await pending;
});
expect(useUsagesPopup.getState().open).toBe(false);
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]);
respond();
before = calls.length;
pending = showUsagesPopup();
await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null }));
await act(() => new Promise((r) => setTimeout(r, 350)));
await act(async () => {
resolve({ locations: [loc], incomplete: null });
await pending;
});
expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: false });
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(calls.slice(before)).toEqual([]);
act(() => dismissUsagesPopup());
});
it("searches Project Files, and Project and Libraries when invoked again", async () => {
render(<UsagesPopup />);
const at = (line: number) => ({ ...loc, range: { start: { line, col: 4 }, end: { line, col: 8 } } });
const libraries = () => (lspReferences.mock.lastCall as unknown[])[5];
const widen = "Press ⌥⌘F7 again to search in 'Project and Libraries'";
// Nothing in Project Files: the hint offers the libraries, searched when invoked again meanwhile.
let before = calls.length;
referenceResponse.mockResolvedValueOnce({ locations: [], incomplete: null, librariesSkipped: true });
await showUsagesPopup();
expect(libraries()).toBe(false);
expect(calls.slice(before)).toEqual([`hint No usages found in Project Files. ${widen}`]);
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null });
await act(() => showUsagesPopup());
expect(libraries()).toBe(true);
expect(useUsagesPopup.getState()).toMatchObject({ open: true, scope: "libraries" });
expect(screen.getByRole("button", { name: "Scope" })).toHaveTextContent("Project and Libraries");
// The scope chooser searches Project Files again.
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null, librariesSkipped: true });
await act(async () => setUsagesPopupScope("project"));
await vi.waitFor(() => expect(useUsagesPopup.getState()).toMatchObject({ loading: false, scope: "project" }));
expect(libraries()).toBe(false);
expect(screen.getByRole("button", { name: "Scope" })).toHaveTextContent("Project Files");
// Switched while Project and Libraries are still searched: that search gives way.
let finish!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
referenceResponse.mockReturnValueOnce(new Promise((r) => (finish = r)));
await act(async () => setUsagesPopupScope("libraries"));
expect(libraries()).toBe(true);
expect(useUsagesPopup.getState()).toMatchObject({ loading: true, scope: "libraries" });
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null, librariesSkipped: true });
await act(async () => setUsagesPopupScope("project"));
await vi.waitFor(() => expect(useUsagesPopup.getState()).toMatchObject({ loading: false, scope: "project" }));
expect(libraries()).toBe(false);
await act(async () => finish({ locations: [at(1), at(2), at(3), at(4)], incomplete: null }));
expect(screen.getAllByRole("option")).toHaveLength(2);
// Invoked again while it shows Project Files: Project and Libraries.
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2), at(3)], incomplete: null });
await act(() => showUsagesPopup());
expect(libraries()).toBe(true);
expect(screen.getAllByRole("option")).toHaveLength(3);
act(() => dismissUsagesPopup());
// The only usage in Project Files is opened; the hint offers the libraries too.
before = calls.length;
referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null, librariesSkipped: true });
await showUsagesPopup();
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", `hint Only one usage in Project Files. ${widen}`]);
referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null });
before = calls.length;
await showUsagesPopup();
expect(libraries()).toBe(true);
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project and Libraries"]);
// Without a hint showing, it searches the symbol at the caret in Project Files again.
referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null, librariesSkipped: true });
await showUsagesPopup();
expect(libraries()).toBe(false);
});
it("discards a pending response after the current popup is dismissed", async () => { it("discards a pending response after the current popup is dismissed", async () => {
let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void; let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
referenceResponse.mockReturnValueOnce( referenceResponse.mockReturnValueOnce(
+10
View File
@@ -1431,6 +1431,10 @@
"No structure to show": "没有可显示的结构", "No structure to show": "没有可显示的结构",
"No tools match.": "没有匹配的工具。", "No tools match.": "没有匹配的工具。",
"No usages found": "未找到用法", "No usages found": "未找到用法",
"No usages found in Project Files": "在项目文件中未找到用法",
"No usages found in Project Files. Press {again} again to search in 'Project and Libraries'": "在项目文件中未找到用法。再按一次 {again} 在“项目和库”中搜索",
"Search in Project and Libraries": "在项目和库中搜索",
"Usages in Project Files": "项目文件中的用法",
"No usages of '{name}' found": "未找到 '{name}' 的用法", "No usages of '{name}' found": "未找到 '{name}' 的用法",
"Node interpreter": "Node 解释器", "Node interpreter": "Node 解释器",
"None": "无", "None": "无",
@@ -1458,6 +1462,11 @@
"Older output is discarded": "较早的输出将被丢弃", "Older output is discarded": "较早的输出将被丢弃",
"Only in comments": "仅在注释中", "Only in comments": "仅在注释中",
"Only one usage in Project Files": "项目文件中只有一个用法", "Only one usage in Project Files": "项目文件中只有一个用法",
"Only one usage in Project and Libraries": "项目和库中只有一个用法",
"Only one usage in Project Files. Press {again} again to search in 'Project and Libraries'": "项目文件中只有一个用法。再按一次 {again} 在“项目和库”中搜索",
"Project and Libraries": "项目和库",
"Usages of '{symbol}' in Project Files": "'{symbol}' 在项目文件中的用法",
"Usages of '{symbol}' in Project and Libraries": "'{symbol}' 在项目和库中的用法",
"Open": "打开", "Open": "打开",
"Open Diff": "打开差异", "Open Diff": "打开差异",
"Open File": "打开文件", "Open File": "打开文件",
@@ -2018,6 +2027,7 @@
"Searches": "搜索", "Searches": "搜索",
"Searching": "正在搜索", "Searching": "正在搜索",
"Searching for usages": "正在搜索用法", "Searching for usages": "正在搜索用法",
"Searching for more usages": "正在搜索更多用法",
"Second stroke": "第二次击键", "Second stroke": "第二次击键",
"Second stroke:": "第二次击键:", "Second stroke:": "第二次击键:",
"Select All": "全选", "Select All": "全选",