fix(lsp): Find usages in unopened files outside the language server's scope (#89)

Reference-based features only asked the language server, which analyses its configured scope (e.g. Pyright's `include`) plus open files, so usages in unopened files outside that scope were silently missing.

Find Usages, Show Usages, usage counts, Safe Delete of a symbol, Rename and incoming calls now also search project files that contain the symbol's name (whole word, same language, .gitignore/excludes respected, unsaved edits included, up to 300 files, cancellable). Candidates the first answer didn't cover are opened in the server temporarily, queried and closed again. Files the user has open are never touched, and diagnostics for the temporary files are dropped. An empty result while the server is still indexing is retried once indexing ends. When results may still be incomplete (indexing, file cap), the usages UI says so. `lsp_references` takes a RequestId and is cancelled by newer searches. Not covered yet: the Safe Delete scan when deleting a file or directory.

Fixes #83

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai
2026-10-27 17:41:20 +08:00
committed by GitHub
parent 482f391fd1
commit 8ec35c94ec
17 changed files with 1116 additions and 97 deletions
+8
View File
@@ -75,8 +75,12 @@ impl ClientHandler {
_ => return,
}
};
let ended = matches!(&event, IdeEvent::Task { done: true, .. });
mgr.sink().emit(event);
self.server.refresh_busy_status(mgr.sink());
if ended {
self.server.progress_ended();
}
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
@@ -88,6 +92,10 @@ impl ClientHandler {
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
// Files opened only for a request (references search) are not the user's problems.
if self.server.suppresses_diagnostics(&path) {
return;
}
let conv = mgr.conv(self.encoding());
let items = params
.diagnostics
+132 -72
View File
@@ -13,6 +13,7 @@ mod handler;
mod inspect;
pub mod jdtls;
mod launch;
mod scope;
mod server;
mod tcp;
mod text;
@@ -23,6 +24,7 @@ mod virtual_doc;
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use launch::{LaunchError, Launcher, ProcessLauncher, search_path};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path};
use caps::{ServerCaps, SyncKind};
@@ -143,6 +145,7 @@ pub struct LspManager {
docs: Mutex<HashMap<DocId, DocState>>,
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
config_hook: RwLock<Option<ConfigHook>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
shutting_down: AtomicBool,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
@@ -183,6 +186,19 @@ fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError {
}
}
/// Sends the user's `didOpen` for `path`, closing a transient copy of the file
/// first (one a references search opened).
fn user_did_open(server: &Server, run: &Running, path: &Path, uri: &lsp::Uri, params: Value) {
if server.take_transient(path, run.generation)
&& let Err(e) = run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } }))
{
tracing::debug!("{}: didClose: {e}", server.name);
}
if let Err(e) = run.peer.notify("textDocument/didOpen", params) {
tracing::debug!("{}: didOpen: {e}", server.name);
}
}
fn unsupported(feature: &str) -> IdeError {
IdeError::unavailable(format!("no running language server supports {feature}"))
}
@@ -235,6 +251,7 @@ impl LspManager {
docs: Mutex::new(HashMap::new()),
apply_edit_hook: RwLock::new(None),
config_hook: RwLock::new(None),
candidates_hook: RwLock::new(None),
shutting_down: AtomicBool::new(false),
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
@@ -299,10 +316,8 @@ impl LspManager {
}
let mut attached = Attached { server: server.clone(), opened_gen: None };
if let Some(run) = server.running() {
if run.caps.read().open_close
&& let Err(e) = run.peer.notify("textDocument/didOpen", d.did_open_params())
{
tracing::debug!("{}: didOpen: {e}", server.name);
if run.caps.read().open_close {
user_did_open(&server, &run, &d.path, &d.uri, d.did_open_params());
}
attached.opened_gen = Some(run.generation);
}
@@ -481,6 +496,7 @@ impl LspManager {
for (id, title) in server.take_progress() {
self.sink.emit(ide_core::IdeEvent::Task { id, title, message: None, fraction: None, done: true });
}
server.progress_ended();
for path in server.take_diagnostic_paths() {
self.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.into(),
@@ -596,10 +612,8 @@ impl LspManager {
let params = state.did_open_params();
for a in &mut state.servers {
if let Some(run) = a.server.running() {
if run.caps.read().open_close
&& let Err(e) = run.peer.notify("textDocument/didOpen", params.clone())
{
tracing::debug!("{}: didOpen: {e}", a.server.name);
if run.caps.read().open_close {
user_did_open(&a.server, &run, &state.path, &state.uri, params.clone());
}
a.opened_gen = Some(run.generation);
}
@@ -945,40 +959,61 @@ impl LspManager {
out.finish(Vec::new())
}
pub async fn references(&self, doc: DocId, pos: Pos, include_declaration: bool) -> IdeResult<Vec<Location>> {
/// References of the symbol at `pos`. With `project_wide`, files outside
/// the servers' analysis scope that mention the name are opened for the
/// search (see [`scope`]); without, only what the servers analyse counts
/// (cheap, for bulk scans over many symbols).
pub async fn references(
&self,
doc: DocId,
pos: Pos,
include_declaration: bool,
project_wide: bool,
cancel: CancellationToken,
) -> IdeResult<References> {
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
let requests = targets.iter().map(|t| {
let mut params = snap.pos_params(t, pos);
params["context"] = json!({ "includeDeclaration": include_declaration });
let (snap, cancel) = (&snap, &cancel);
async move {
(
t,
self.call::<_, Option<Vec<lsp::Location>>>(
t,
"textDocument/references",
params,
None,
LONG_TIMEOUT,
)
.await,
)
let query = || async move {
let mut params = snap.pos_params(t, pos);
params["context"] = json!({ "includeDeclaration": include_declaration });
let locs: Option<Vec<lsp::Location>> =
self.call(t, "textDocument/references", params, Some(cancel), LONG_TIMEOUT).await?;
let conv = self.conv(t.enc());
Ok(locs.unwrap_or_default().iter().filter_map(|l| conv.location(l)).collect::<Vec<Location>>())
};
let files =
|v: &Vec<Location>| v.iter().filter(|l| l.uri.is_none()).map(|l| l.path.to_path_buf()).collect();
let result = if project_wide {
self.widened(snap, t, pos, cancel, query, files).await
} else {
query().await.map(|first| {
let empty = first.is_empty();
scope::Widened::only(first, empty, &t.server)
})
};
(t, result)
}
});
let mut out = Outcome::new();
for (t, r) in join_all(requests).await {
match r {
Ok(locs) => {
let conv = self.conv(t.enc());
let locs: Vec<Location> =
locs.unwrap_or_default().iter().filter_map(|l| conv.location(l)).collect();
Ok(w) => {
let acc = out.value.get_or_insert_with(References::default);
let locs: Vec<Location> = w.first.into_iter().chain(w.second.into_iter().flatten()).collect();
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
out.value.get_or_insert_with(Vec::new).extend(locs);
acc.locations.extend(locs);
acc.incomplete = acc.incomplete.or(w.incomplete);
}
Err(e) => out.fail(e),
}
}
let mut v = out.finish(Vec::new())?;
convert::dedup_locations(&mut v);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut v = out.finish(References::default())?;
convert::dedup_locations(&mut v.locations);
Ok(v)
}
@@ -1208,13 +1243,29 @@ impl LspManager {
})
}
/// Renames across the project: like [`Self::references`], files outside the
/// server's analysis scope that mention the name are opened for the request.
pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?;
let t = &targets[0];
let mut params = snap.pos_params(t, pos);
params["newName"] = json!(new_name);
let r: Option<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?;
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
let query = || async {
let mut params = snap.pos_params(t, pos);
params["newName"] = json!(new_name);
let r: Option<lsp::WorkspaceEdit> = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?;
Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default())
};
let files = |e: &WorkspaceEdit| {
e.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, .. } => Some(path.to_path_buf()),
_ => None,
})
.collect()
};
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
// The second edit covers the first: its files stay analysed.
Ok(w.second.unwrap_or(w.first))
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
@@ -1263,16 +1314,17 @@ impl LspManager {
/// Some servers (tsserver) only answer for files of a loaded project: when
/// `t` has no document open, one moved file of its languages is opened for the request.
fn open_for_file_op(&self, t: &Target, moves: &[(PathBuf, PathBuf, bool)]) -> Option<lsp::Uri> {
fn open_for_file_op(&self, t: &Target, moves: &[(PathBuf, PathBuf, bool)]) -> scope::TransientFiles<'_> {
let mut opened = scope::TransientFiles::empty(self, t);
if !t.caps().open_close {
return None;
return opened;
}
let docs = self.docs.lock();
let busy = docs
.values()
.any(|d| d.servers.iter().any(|a| a.server.id == t.server.id && a.opened_gen == Some(t.run.generation)));
let busy =
self.docs.lock().values().any(|d| {
d.servers.iter().any(|a| a.server.id == t.server.id && a.opened_gen == Some(t.run.generation))
});
if busy {
return None;
return opened;
}
let languages = t.server.languages();
let candidate =
@@ -1287,14 +1339,10 @@ impl LspManager {
continue;
}
let Ok(text) = std::fs::read_to_string(&file) else { continue };
let uri = uri::path_to_uri(&file);
let params =
json!({ "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } });
if t.run.peer.notify("textDocument/didOpen", params).is_ok() {
return Some(uri);
}
opened.hold(&file, &language, &text);
break;
}
None
opened
}
/// Asks the servers for the edits that keep references intact when files or
@@ -1320,9 +1368,7 @@ impl LspManager {
let opened = self.open_for_file_op(&t, &moves);
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
self.call(&t, "workspace/willRenameFiles", params, Some(&cancel), LONG_TIMEOUT).await;
if let Some(uri) = opened {
let _ = t.run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } }));
}
drop(opened);
match r {
Ok(Some(edit)) => out.edit.files.extend(convert::workspace_edit(&self.conv(t.enc()), edit).files),
Ok(None) => {}
@@ -1519,32 +1565,46 @@ impl LspManager {
let items: Option<Vec<lsp::CallHierarchyItem>> =
self.call(t, "textDocument/prepareCallHierarchy", snap.pos_params(t, pos), None, DEFAULT_TIMEOUT).await?;
let items = items.filter(|i| !i.is_empty()).ok_or_else(no_hierarchy_target)?;
let mut out = Vec::new();
for item in items {
let params = json!({ "item": item });
match direction {
HierarchyDirection::Incoming => {
let calls: Option<Vec<lsp::CallHierarchyIncomingCall>> =
self.call(t, "callHierarchy/incomingCalls", params, None, LONG_TIMEOUT).await?;
let conv = self.conv(t.enc());
for c in calls.unwrap_or_default() {
let (path, _) = uri_target(c.from.uri.as_str());
let ranges = c.from_ranges.iter().map(|r| conv.range(&path, *r)).collect();
out.extend(convert::call_item(&conv, &c.from, ranges));
let query = || async {
let mut out = Vec::new();
for item in &items {
let params = json!({ "item": item });
match direction {
HierarchyDirection::Incoming => {
let calls: Option<Vec<lsp::CallHierarchyIncomingCall>> =
self.call(t, "callHierarchy/incomingCalls", params, None, LONG_TIMEOUT).await?;
let conv = self.conv(t.enc());
for c in calls.unwrap_or_default() {
let (path, _) = uri_target(c.from.uri.as_str());
let ranges = c.from_ranges.iter().map(|r| conv.range(&path, *r)).collect();
out.extend(convert::call_item(&conv, &c.from, ranges));
}
}
}
HierarchyDirection::Outgoing => {
let calls: Option<Vec<lsp::CallHierarchyOutgoingCall>> =
self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?;
let conv = self.conv(t.enc());
let (src, _) = uri_target(item.uri.as_str());
for c in calls.unwrap_or_default() {
let ranges = c.from_ranges.iter().map(|r| conv.range(&src, *r)).collect();
out.extend(convert::call_item(&conv, &c.to, ranges));
HierarchyDirection::Outgoing => {
let calls: Option<Vec<lsp::CallHierarchyOutgoingCall>> =
self.call(t, "callHierarchy/outgoingCalls", params, None, LONG_TIMEOUT).await?;
let conv = self.conv(t.enc());
let (src, _) = uri_target(item.uri.as_str());
for c in calls.unwrap_or_default() {
let ranges = c.from_ranges.iter().map(|r| conv.range(&src, *r)).collect();
out.extend(convert::call_item(&conv, &c.to, ranges));
}
}
}
}
}
Ok(out)
};
let out = match direction {
// Callers are references: they may sit in files outside the server's scope.
HierarchyDirection::Incoming => {
let files = |v: &Vec<HierarchyItem>| {
v.iter().filter(|i| i.uri.is_none()).map(|i| i.path.to_path_buf()).collect()
};
let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?;
w.second.unwrap_or(w.first)
}
HierarchyDirection::Outgoing => query().await?,
};
self.note_virtual(&t.server, out.iter().map(|i| i.uri.as_deref()));
Ok(out)
}
+248
View File
@@ -0,0 +1,248 @@
//! Reference searches beyond a server's analysis scope.
//!
//! Servers answer `textDocument/references` (and rename, incoming calls) from
//! the files they analyse: their configured scope (Pyright's `include`, a
//! `tsconfig.json`, a compilation database) plus the open documents. Usages in
//! other project files are silently missing. So, as a word index would, the
//! project files of the server's languages that contain the symbol's name as a
//! whole word (found through the host's [`CandidateFn`]) and that the first
//! answer did not cover are opened on the server for a second request, then
//! closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::types::ReferencesIncomplete;
use crate::{DocSnap, LspManager, Target};
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
use lsp_types as lsp;
use serde_json::json;
use std::collections::HashSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
/// Files opened for one search at most.
pub const MAX_CANDIDATES: usize = 300;
/// Larger files (generated code, bundles) are not opened.
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
/// How long a search that found nothing waits for a server to finish indexing.
const INDEX_WAIT: Duration = Duration::from_secs(10);
/// Project files that may mention a symbol.
#[derive(Clone, Debug)]
pub struct CandidateQuery {
/// Matched as a whole word, case-sensitively.
pub word: String,
/// Only files under this directory (the server's workspace root).
pub root: PathBuf,
/// Only files of these languages.
pub languages: Vec<LanguageId>,
pub limit: usize,
}
#[derive(Clone, Debug, Default)]
pub struct CandidateFiles {
pub files: Vec<PathBuf>,
/// More files matched than `limit`.
pub limit_hit: bool,
}
/// Finds [`CandidateFiles`] (honouring the project's ignore rules); runs on a
/// blocking thread and should stop early when cancelled.
pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync;
/// A reference-based answer and, when files were opened for it, the second one.
pub(crate) struct Widened<R> {
pub first: R,
pub second: Option<R>,
pub incomplete: Option<ReferencesIncomplete>,
}
impl<R> Widened<R> {
/// An answer from the server's analysis scope alone; `empty`: it found nothing.
pub fn only(first: R, empty: bool, server: &Server) -> Self {
let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing);
Self { first, second: None, incomplete }
}
}
/// The name a search is about: the word at `pos` (a PHP `$` sigil dropped).
fn search_word(snap: &DocSnap, pos: Pos) -> Option<String> {
let r = snap.text.word_range_at(pos)?;
let text = snap.text.as_str();
let word = text.get(snap.text.byte_offset(r.start)..snap.text.byte_offset(r.end))?.trim_start_matches('$');
(!word.is_empty() && !word.chars().all(|c| c.is_ascii_digit())).then(|| word.to_owned())
}
impl LspManager {
/// Finds the project files a references search may open (see the module docs).
pub fn set_candidates_hook(&self, hook: Box<CandidateFn>) {
*self.candidates_hook.write() = Some(Arc::from(hook));
}
async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option<CandidateFiles> {
let hook = self.candidates_hook.read().clone()?;
let query = CandidateQuery {
word,
root: t.server.root.clone(),
languages: t.server.languages(),
limit: MAX_CANDIDATES,
};
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || hook(&query, &cancel)).await.ok()
}
/// Runs `query` on `t`; then again with the project files that mention the
/// symbol at `pos` and are not in `covered(first)` opened on the server.
/// A first answer that is empty while the server indexes is retried once
/// indexing ends.
pub(crate) async fn widened<R, F, Fut>(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: &CancellationToken,
query: F,
covered: impl Fn(&R) -> Vec<PathBuf>,
) -> IdeResult<Widened<R>>
where
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
let busy = t.server.is_busy();
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
let candidates = async {
match word {
Some(w) => self.candidates(t, w, cancel).await,
None => None,
}
};
let (first, candidates) = futures::join!(query(), candidates);
let mut first = first?;
if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await {
first = query().await?;
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
// Servers report analysis progress often; only "nothing found" while it lasts is suspect.
let mut incomplete =
(t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) };
if candidates.limit_hit {
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
}
let known: HashSet<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
let files: Vec<PathBuf> = candidates.files.into_iter().filter(|f| !known.contains(f)).collect();
if files.is_empty() {
return Ok(Widened { first, second: None, incomplete });
}
let opened = self.open_transient(t, files, cancel).await;
if opened.files.is_empty() {
return Ok(Widened { first, second: None, incomplete });
}
tracing::debug!("{}: opened {} more files for a references search", t.server.name, opened.files.len());
let second = query().await;
drop(opened);
match second {
Ok(second) => {
if !covered(&second).is_empty() && incomplete == Some(ReferencesIncomplete::Indexing) {
incomplete = None;
}
Ok(Widened { first, second: Some(second), incomplete })
}
Err(e) if cancel.is_cancelled() => Err(e),
Err(e) => {
tracing::debug!("{}: widened references search: {}", t.server.name, e.message);
Ok(Widened { first, second: None, incomplete })
}
}
}
/// Opens `files` on `t` (read from disk) unless the user has them open;
/// they are closed when the returned guard drops.
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
let read = {
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
files
.into_iter()
.take_while(|_| !cancel.is_cancelled())
.filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_CANDIDATE_BYTES))
.filter_map(|f| std::fs::read_to_string(&f).ok().map(|text| (f, text)))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
};
let mut guard = TransientFiles { mgr: self, server: t.server.clone(), run: t.run.clone(), files: Vec::new() };
if cancel.is_cancelled() {
return guard;
}
let docs = self.docs.lock();
for (path, text) in read {
if docs.values().any(|d| d.path == path) {
continue;
}
let uri = crate::uri::path_to_uri(&path);
if t.server.hold_transient(&path, t.run.generation) {
let language = self.languages.detect(&path, text.lines().next());
let params = json!({
"textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text }
});
if let Err(e) = t.run.peer.notify("textDocument/didOpen", params) {
tracing::debug!("{}: didOpen: {e}", t.server.name);
}
}
guard.files.push((path, uri));
}
guard
}
}
/// Files opened by [`LspManager::open_transient`]; closed on drop.
pub(crate) struct TransientFiles<'a> {
mgr: &'a LspManager,
server: Arc<Server>,
run: Running,
pub files: Vec<(PathBuf, lsp::Uri)>,
}
impl TransientFiles<'_> {
/// Opens one file outside a references search (a file operation needs a loaded project).
pub(crate) fn hold(&mut self, path: &Path, language: &LanguageId, text: &str) {
let _docs = self.mgr.docs.lock();
let uri = crate::uri::path_to_uri(path);
if self.server.hold_transient(path, self.run.generation) {
let params =
json!({ "textDocument": { "uri": uri, "languageId": language.as_str(), "version": 0, "text": text } });
if let Err(e) = self.run.peer.notify("textDocument/didOpen", params) {
tracing::debug!("{}: didOpen: {e}", self.server.name);
}
}
self.files.push((path.to_owned(), uri));
}
}
impl<'a> TransientFiles<'a> {
pub(crate) fn empty(mgr: &'a LspManager, t: &Target) -> Self {
Self { mgr, server: t.server.clone(), run: t.run.clone(), files: Vec::new() }
}
}
impl Drop for TransientFiles<'_> {
fn drop(&mut self) {
if self.files.is_empty() {
return;
}
// Under the documents lock, so a user `didOpen` of the same file can't interleave.
let _docs = self.mgr.docs.lock();
for (path, uri) in self.files.drain(..) {
if self.server.release_transient(&path, self.run.generation)
&& let Err(e) = self.run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": uri } }))
{
tracing::debug!("{}: didClose: {e}", self.server.name);
}
}
}
}
+108
View File
@@ -17,6 +17,9 @@ use tokio::sync::{Notify, watch};
const STABLE_UPTIME: Duration = Duration::from_secs(180);
const MAX_RESTARTS: usize = 3;
const STDERR_LINES: usize = 50;
/// Diagnostics a server publishes for a transiently opened file shortly after
/// its `didClose` (analysis that was already running) are dropped too.
const TRANSIENT_GRACE: Duration = Duration::from_secs(3);
/// A live, initialized instance.
#[derive(Clone)]
@@ -49,6 +52,17 @@ struct State {
conn: Conn,
}
/// A file opened on the server by the client itself (not by the user) for the
/// duration of a request, e.g. to widen a references search.
struct Transient {
/// Requests holding the file open.
holders: usize,
generation: u64,
/// The user opened the file meanwhile: its `didOpen` replaced ours.
taken: bool,
closed_at: Option<Instant>,
}
pub struct Progress {
pub task: TaskId,
pub title: String,
@@ -80,6 +94,9 @@ pub struct Server {
published: Mutex<HashMap<PathBuf, Instant>>,
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
pub published_notify: Notify,
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
}
impl Server {
@@ -102,6 +119,8 @@ impl Server {
diagnostics: Mutex::new(HashMap::new()),
published: Mutex::new(HashMap::new()),
published_notify: Notify::new(),
idle_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
}
}
@@ -305,6 +324,95 @@ impl Server {
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
}
/// Whether the server reports work in progress (indexing, analysis).
pub fn is_busy(&self) -> bool {
!self.progress.lock().is_empty()
}
/// Called after a `$/progress` task ended.
pub fn progress_ended(&self) {
if !self.is_busy() {
self.idle_notify.notify_waiters();
}
}
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let notified = self.idle_notify.notified();
if !self.is_busy() || self.running().is_none() {
return !self.is_busy();
}
tokio::select! {
() = notified => {}
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
() = cancel.cancelled() => return false,
}
}
}
// ---- transiently opened files ----
/// Registers a holder of the transient file `path` on instance `generation`;
/// true when the caller must send `didOpen` (first holder).
pub fn hold_transient(&self, path: &Path, generation: u64) -> bool {
let mut map = self.transient.lock();
map.retain(|_, t| t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE));
let t =
map.entry(path.to_owned()).or_insert(Transient { holders: 0, generation, taken: false, closed_at: None });
if t.holders == 0 || t.generation != generation {
*t = Transient { holders: 1, generation, taken: false, closed_at: None };
return true;
}
t.holders += 1;
false
}
/// Drops a holder of `path`; true when the caller must send `didClose`
/// (last holder, same instance, and the user did not open the file meanwhile).
pub fn release_transient(&self, path: &Path, generation: u64) -> bool {
let mut map = self.transient.lock();
let Some(t) = map.get_mut(path) else { return false };
if t.holders == 0 {
return false;
}
t.holders -= 1;
if t.holders > 0 {
return false;
}
let close = !t.taken && t.generation == generation;
if close {
t.closed_at = Some(Instant::now());
} else {
map.remove(path);
}
close
}
/// The user opens `path` on instance `generation`: true when a transient
/// copy is open there and must be closed before the user's `didOpen`.
pub fn take_transient(&self, path: &Path, generation: u64) -> bool {
let mut map = self.transient.lock();
let Some(t) = map.get_mut(path) else { return false };
let open = t.holders > 0 && !t.taken && t.generation == generation;
if t.holders > 0 {
t.taken = true;
} else {
map.remove(path);
}
open
}
/// Whether diagnostics published for `path` belong to a transient copy
/// and must not reach the Problems view.
pub fn suppresses_diagnostics(&self, path: &Path) -> bool {
self.transient
.lock()
.get(path)
.is_some_and(|t| !t.taken && (t.holders > 0 || t.closed_at.is_some_and(|c| c.elapsed() < TRANSIENT_GRACE)))
}
// ---- bookkeeping ----
pub fn push_stderr(&self, line: String) {
+17
View File
@@ -322,3 +322,20 @@ pub struct FileRenameEdits {
/// Servers that failed; their references are not updated.
pub errors: Vec<String>,
}
/// Why a references search may have missed usages.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ReferencesIncomplete {
/// A language server was still indexing.
Indexing,
/// More project files mention the name than were checked.
TooManyFiles,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct References {
pub locations: Vec<Location>,
pub incomplete: Option<ReferencesIncomplete>,
}
+349 -4
View File
@@ -27,9 +27,13 @@ struct MockState {
/// uri → text as reconstructed by the mock from didOpen/didChange.
docs: Mutex<HashMap<String, String>>,
responses: Mutex<HashMap<String, Value>>,
/// Computes a response from the request and the open documents (before `responses`).
responder: Mutex<Option<Responder>>,
peer: Mutex<Option<Peer>>,
}
type Responder = Arc<dyn Fn(&MockState, &str, &Value) -> Option<Value> + Send + Sync>;
impl MockState {
fn notified(&self, method: &str) -> Vec<Value> {
self.notifications.lock().iter().filter(|(m, _)| m == method).map(|(_, p)| p.clone()).collect()
@@ -111,11 +115,17 @@ impl Handler for MockHandler {
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
let s = &self.0;
s.requests.lock().push((method.to_owned(), params));
s.requests.lock().push((method.to_owned(), params.clone()));
match method {
"initialize" => Ok(json!({ "capabilities": s.caps.lock().clone(), "serverInfo": { "name": "mock" } })),
"shutdown" => Ok(Value::Null),
_ => Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null)),
_ => {
let responder = s.responder.lock().clone();
if let Some(v) = responder.and_then(|f| f(s, method, &params)) {
return Ok(v);
}
Ok(s.responses.lock().get(method).cloned().unwrap_or(Value::Null))
}
}
}
}
@@ -801,7 +811,7 @@ async fn navigation_rename_format_and_decorations() {
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
s.respond("textDocument/references", json!([{ "uri": uri, "range": r(0,3,0,6) }, { "uri": uri, "range": r(1,0,1,3) }, { "uri": uri, "range": r(1,0,1,3) }]));
let refs = h.mgr.references(doc, Pos::new(1, 1), true).await.unwrap();
let refs = h.mgr.references(doc, Pos::new(1, 1), true, true, Default::default()).await.unwrap().locations;
assert_eq!(refs.len(), 2);
assert_eq!(s.requested("textDocument/references")[0]["context"]["includeDeclaration"], true);
@@ -987,7 +997,7 @@ async fn virtual_locations_and_text_document_content() {
"textDocument/references",
json!([{ "uri": uri_of(&path), "range": r(0, 4, 0, 9) }, { "uri": lib, "range": r(2, 4, 2, 9) }]),
);
let refs = h.mgr.references(doc, Pos::new(0, 5), true).await.unwrap();
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap().locations;
assert_eq!(refs.iter().map(|l| l.uri.is_some()).collect::<Vec<_>>(), vec![false, true]);
s.respond(
"textDocument/prepareTypeHierarchy",
@@ -1301,3 +1311,338 @@ async fn css_family_configuration_returns_default_objects() {
assert_eq!(state.requested("initialize")[0]["initializationOptions"]["provideFormatter"], true);
}
}
// ------------------------------------------------------------------ references beyond the analysis scope
const WORD: &str = "target";
/// Locations of [`WORD`] in `text` (ASCII, so UTF-16 columns are byte offsets).
fn occurrences(uri: &str, text: &str) -> Vec<Value> {
let mut out = Vec::new();
for (line, l) in text.lines().enumerate() {
for (col, _) in l.match_indices(WORD) {
let end = col + WORD.len();
out.push(json!({ "uri": uri, "range": {
"start": { "line": line, "character": col }, "end": { "line": line, "character": end } } }));
}
}
out
}
/// A server that only analyses its open documents (minus `blind` URIs), like
/// Pyright for files outside its `include`.
fn open_files_only(blind: Vec<String>) -> Responder {
Arc::new(move |s, method, _| {
let docs: Vec<(String, String)> =
s.docs.lock().iter().filter(|(u, _)| !blind.contains(u)).map(|(u, t)| (u.clone(), t.clone())).collect();
let locs = || docs.iter().flat_map(|(u, t)| occurrences(u, t));
match method {
"textDocument/references" => Some(Value::Array(locs().collect())),
"textDocument/rename" => {
let mut changes = serde_json::Map::new();
for loc in locs() {
let edits = changes.entry(loc["uri"].as_str().unwrap()).or_insert_with(|| json!([]));
edits.as_array_mut().unwrap().push(json!({ "range": loc["range"], "newText": "renamed" }));
}
Some(json!({ "changes": changes }))
}
_ => None,
}
})
}
/// Candidate files: those directly under the root that contain the word; records the queries.
fn grep_candidates(h: &Harness) -> Arc<Mutex<Vec<ide_lsp::CandidateQuery>>> {
let seen = Arc::new(Mutex::new(Vec::new()));
let log = seen.clone();
h.mgr.set_candidates_hook(Box::new(move |q, _| {
log.lock().push(q.clone());
let mut files: Vec<PathBuf> = std::fs::read_dir(&q.root)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| std::fs::read_to_string(p).is_ok_and(|t| t.contains(&q.word)))
.collect();
files.sort();
ide_lsp::CandidateFiles { files, limit_hit: false }
}));
seen
}
fn uris(params: &[Value]) -> Vec<String> {
params.iter().map(|p| p["textDocument"]["uri"].as_str().unwrap().to_owned()).collect()
}
fn diagnostics_for(h: &Harness, path: &Path) -> usize {
h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Diagnostics { path: p, .. } if p == path)).count()
}
fn scope_harness() -> (Harness, Arc<MockState>) {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
*s.responder.lock() = Some(open_files_only(vec![]));
(h, s)
}
#[tokio::test(flavor = "multi_thread")]
async fn references_open_unopened_files_that_mention_the_name() {
let (h, s) = scope_harness();
let seen = grep_candidates(&h);
let b = h.root.join("b.mk");
let c = h.root.join("c.mk");
std::fs::write(&b, "call target\n").unwrap();
std::fs::write(&c, "nothing here\n").unwrap();
let (doc, a) = h.open("a.mk", "def target\n");
let (_, d) = h.open("d.mk", "use target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 2).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
let mut paths: Vec<PathBuf> = refs.locations.iter().map(|l| l.path.to_path_buf()).collect();
paths.sort();
assert_eq!(paths, vec![a.clone(), b.clone(), d.clone()]);
assert_eq!(refs.incomplete, None);
let q = seen.lock()[0].clone();
assert_eq!((q.word.as_str(), q.root.as_path(), q.limit), (WORD, h.root.as_path(), ide_lsp::MAX_CANDIDATES));
assert_eq!(q.languages, vec![h.lang.clone()]);
// Only the unopened candidate was opened (from disk), then closed again.
let opens = s.notified("textDocument/didOpen");
let temp: Vec<&Value> = opens.iter().filter(|p| p["textDocument"]["uri"] == uri_of(&b)).collect();
assert_eq!(temp.len(), 1);
assert_eq!(temp[0]["textDocument"]["version"], 0);
assert_eq!(temp[0]["textDocument"]["text"], "call target\n");
assert!(!uris(&opens).contains(&uri_of(&c)));
assert_eq!(uris(&opens).iter().filter(|u| **u == uri_of(&d)).count(), 1);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
assert_eq!(s.requested("textDocument/references").len(), 2);
assert_eq!(s.docs.lock().len(), 2);
// When every candidate file is already in the answer, no second request is made.
std::fs::remove_file(&b).unwrap();
h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(s.requested("textDocument/references").len(), 3);
// Without project_wide only the server's scope counts: no project search.
std::fs::write(&b, "call target\n").unwrap();
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(seen.lock().len(), 2);
}
#[tokio::test(flavor = "multi_thread")]
async fn references_never_reopen_or_close_the_users_files() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
grep_candidates(&h);
let (doc, _) = h.open("a.mk", "def target\n");
// Open, mentions the name, but the server does not report it.
let (_, d) = h.open("d.mk", "use target\n");
*s.responder.lock() = Some(open_files_only(vec![uri_of(&d)]));
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 2).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert_eq!(uris(&s.notified("textDocument/didOpen")).iter().filter(|u| **u == uri_of(&d)).count(), 1);
assert!(s.notified("textDocument/didClose").is_empty());
assert_eq!(s.requested("textDocument/references").len(), 1);
assert!(s.docs.lock().contains_key(&uri_of(&d)));
}
#[tokio::test(flavor = "multi_thread")]
async fn references_report_a_capped_candidate_search() {
let (h, s) = scope_harness();
h.mgr.set_candidates_hook(Box::new(|q, _| ide_lsp::CandidateFiles {
files: vec![q.root.join("b.mk")],
limit_hit: true,
}));
std::fs::write(h.root.join("b.mk"), "target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
}
#[tokio::test(flavor = "multi_thread")]
async fn cancelling_a_references_search_stops_the_candidate_search_and_closes_files() {
let (h, s) = scope_harness();
h.mgr.set_candidates_hook(Box::new(|_, cancel| {
while !cancel.is_cancelled() {
std::thread::sleep(Duration::from_millis(5));
}
ide_lsp::CandidateFiles::default()
}));
let (doc, _) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
let cancel = ide_core::CancellationToken::new();
let later = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
later.cancel();
});
let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
// Cancelled while the server answers with a file opened for the search.
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
grep_candidates(&h);
let slow = uri_of(&b);
let inner = open_files_only(vec![]);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if s.docs.lock().contains_key(&slow) {
std::thread::sleep(Duration::from_millis(600));
}
inner(s, method, params)
}));
let cancel = ide_core::CancellationToken::new();
let later = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
later.cancel();
});
let started = std::time::Instant::now();
let err = h.mgr.references(doc, Pos::new(0, 5), true, true, cancel).await.unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
assert!(started.elapsed() < Duration::from_millis(550));
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn diagnostics_of_files_opened_for_a_search_stay_out_of_problems() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
grep_candidates(&h);
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
let (doc, a) = h.open("a.mk", "def target\n");
let diag = json!([{ "range": {"start":{"line":0,"character":0},"end":{"line":0,"character":1}}, "message": "e" }]);
let inner = open_files_only(vec![]);
let (temp, user, d) = (uri_of(&b), uri_of(&a), diag.clone());
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if s.docs.lock().contains_key(&temp) {
for uri in [&temp, &user] {
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri, "diagnostics": d })).unwrap();
}
}
inner(s, method, params)
}));
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 2);
// Published right after the didClose (analysis still running): dropped too.
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": diag })).unwrap();
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&a), "diagnostics": [] })).unwrap();
eventually(|| diagnostics_for(&h, &a) == 2).await;
assert_eq!(diagnostics_for(&h, &b), 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn opening_a_file_during_a_search_takes_it_over() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
grep_candidates(&h);
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
let (doc, _) = h.open("a.mk", "def target\n");
let slow = uri_of(&b);
let inner = open_files_only(vec![]);
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
if s.docs.lock().get(&slow).is_some_and(|t| t.starts_with("call")) {
std::thread::sleep(Duration::from_millis(400));
}
inner(s, method, params)
}));
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let mgr = h.mgr.clone();
let search = tokio::spawn(async move { mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await });
eventually(|| s.docs.lock().contains_key(&uri_of(&b))).await;
let user_doc = DocId::next();
h.mgr.did_open(user_doc, &b, &h.lang, 7, "edited target\n");
search.await.unwrap().unwrap();
// The temporary copy was closed before the user's didOpen, and the user's stays open.
let events: Vec<(String, Value)> = s
.notifications
.lock()
.iter()
.filter(|(m, p)| m.starts_with("textDocument/did") && p["textDocument"]["uri"] == uri_of(&b))
.map(|(m, p)| (m.clone(), p["textDocument"]["version"].clone()))
.collect();
assert_eq!(
events,
vec![
("textDocument/didOpen".to_owned(), json!(0)),
("textDocument/didClose".to_owned(), Value::Null),
("textDocument/didOpen".to_owned(), json!(7)),
]
);
assert_eq!(s.docs.lock().get(&uri_of(&b)).map(String::as_str), Some("edited target\n"));
// Its diagnostics are the user's again.
s.peer().notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&b), "diagnostics": [] })).unwrap();
eventually(|| diagnostics_for(&h, &b) == 1).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn an_empty_answer_while_indexing_is_retried_when_indexing_ends() {
let h = harness(&[("mock", incremental_caps())]);
let s = h.launcher.state("mock");
let (doc, path) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
let peer = s.peer();
peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let (state, uri) = (s.clone(), uri_of(&path));
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
state.respond("textDocument/references", Value::Array(occurrences(&uri, "def target\n")));
peer.notify("$/progress", json!({ "token": "idx", "value": { "kind": "end" } })).unwrap();
});
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 1);
assert_eq!(refs.incomplete, None);
assert_eq!(s.requested("textDocument/references").len(), 2);
// While the server works, results are taken as they are; finding nothing is flagged.
s.peer()
.notify("$/progress", json!({ "token": "again", "value": { "kind": "begin", "title": "Indexing" } }))
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
assert_eq!((refs.locations.len(), refs.incomplete), (1, None));
s.respond("textDocument/references", json!([]));
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(refs.locations.len(), 0);
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_edits_files_outside_the_analysis_scope() {
let (h, s) = scope_harness();
grep_candidates(&h);
let b = h.root.join("b.mk");
std::fs::write(&b, "call target\n").unwrap();
let (doc, a) = h.open("a.mk", "def target\n");
h.wait_status("mock", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let edit = h.mgr.rename(doc, Pos::new(0, 5), "renamed".into()).await.unwrap();
let mut paths: Vec<PathBuf> = edit
.files
.iter()
.map(|f| match f {
FileEdit::Edit { path, .. } => path.to_path_buf(),
_ => panic!("unexpected {f:?}"),
})
.collect();
paths.sort();
assert_eq!(paths, vec![a, b.clone()]);
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
}
+2
View File
@@ -14,6 +14,7 @@ mod mask;
mod pattern;
mod query;
mod replace;
mod words;
pub use files::{ScopeFile, scope_files};
pub use mask::FileMask;
@@ -22,6 +23,7 @@ pub use replace::{
ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceSummary, ReplaceTarget, apply_edits, preserve_case, preview,
replace_files, replacement_edits,
};
pub use words::{WordFiles, WordQuery, files_with_word};
use grep_matcher::{Captures, Matcher};
use ide_core::{CancellationToken, IdeError, IdeResult};
+118
View File
@@ -0,0 +1,118 @@
//! Files that mention an identifier: the candidates a reference search checks.
use crate::{Overlay, PathFilter, SearchContext, SearchQuery, SearchScope};
use ide_core::{CancellationToken, IdeResult};
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Clone, Debug)]
pub struct WordQuery {
/// Matched as a whole word, case-sensitively.
pub word: String,
/// Directory searched (absolute, or relative to the search root).
pub dir: PathBuf,
/// File masks never searched (excluded roots, ignored names).
pub exclude: Vec<String>,
/// Files returned at most.
pub limit: usize,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WordFiles {
pub files: Vec<PathBuf>,
/// More files matched than `limit`; the search stopped early.
pub limit_hit: bool,
}
/// Files under `query.dir` containing `query.word` as a whole word, in walk
/// order (unsaved buffers through `overlay`, `.gitignore`d and hidden files
/// skipped like Find in Files). Stops at `query.limit` files or when cancelled.
pub fn files_with_word(
root: &std::path::Path,
query: &WordQuery,
filter: Option<PathFilter<'_>>,
overlay: Overlay<'_>,
cancel: &CancellationToken,
) -> IdeResult<WordFiles> {
let search = SearchQuery {
pattern: query.word.clone(),
is_regex: false,
case_sensitive: true,
whole_word: true,
include: Vec::new(),
exclude: query.exclude.clone(),
scope: SearchScope::Directory { path: query.dir.clone().into() },
max_matches: u32::MAX,
context: SearchContext::Any,
};
let stop = cancel.child_token();
let found = Mutex::new(WordFiles::default());
crate::search_filtered(root, &search, filter, overlay, &stop, &|file| {
let mut found = found.lock().unwrap_or_else(|e| e.into_inner());
if found.files.len() < query.limit {
found.files.push(file.path.into_path_buf());
} else {
found.limit_hit = true;
stop.cancel();
}
})?;
Ok(found.into_inner().unwrap_or_else(|e| e.into_inner()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn query(word: &str, limit: usize) -> WordQuery {
WordQuery { word: word.into(), dir: PathBuf::from("."), exclude: vec!["build/**".into()], limit }
}
#[test]
fn whole_word_matches_in_kept_files_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("pkg/ignored")).unwrap();
fs::create_dir_all(root.join("build")).unwrap();
fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
fs::write(root.join("pkg/use.py"), "x.get_value(1)\n").unwrap();
fs::write(root.join("pkg/longer.py"), "get_values()\n").unwrap();
fs::write(root.join("pkg/case.py"), "Get_Value()\n").unwrap();
fs::write(root.join("pkg/notes.txt"), "get_value\n").unwrap();
fs::write(root.join("pkg/ignored/gen.py"), "get_value\n").unwrap();
fs::write(root.join("build/out.py"), "get_value\n").unwrap();
fs::write(root.join("pkg/open.py"), "nothing on disk\n").unwrap();
let unsaved = root.join("pkg/open.py");
let overlay = |p: &std::path::Path| (p == unsaved).then(|| "get_value()".to_owned());
let py = |rel: &str| rel.ends_with(".py");
let mut found =
files_with_word(root, &query("get_value", 10), Some(&py), &overlay, &CancellationToken::new()).unwrap();
found.files.sort();
assert_eq!(
found,
WordFiles { files: vec![root.join("pkg/open.py"), root.join("pkg/use.py")], limit_hit: false }
);
}
#[test]
fn stops_at_the_limit() {
let dir = tempfile::tempdir().unwrap();
for i in 0..5 {
fs::write(dir.path().join(format!("f{i}.py")), "name\n").unwrap();
}
let found = files_with_word(dir.path(), &query("name", 2), None, &|_| None, &CancellationToken::new()).unwrap();
assert_eq!((found.files.len(), found.limit_hit), (2, true));
let all = files_with_word(dir.path(), &query("name", 5), None, &|_| None, &CancellationToken::new()).unwrap();
assert_eq!((all.files.len(), all.limit_hit), (5, false));
}
#[test]
fn a_cancelled_search_finds_nothing() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.py"), "name\n").unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let found = files_with_word(dir.path(), &query("name", 5), None, &|_| None, &cancel).unwrap();
assert!(found.files.is_empty());
}
}
+11 -2
View File
@@ -106,16 +106,25 @@ pub async fn lsp_virtual_document(
project!(window, state).lsp.virtual_document(&uri).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
/// outside the language server's analysis scope; bulk scans over many symbols
/// leave it off. Cancel with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn lsp_references(
window: Window,
state: State<'_, AppState>,
request: RequestId,
doc: DocId,
pos: Pos,
include_declaration: bool,
) -> IdeResult<Vec<Location>> {
project!(window, state).lsp.references(doc, pos, include_declaration).await
project_wide: bool,
) -> IdeResult<References> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
state.end_request(request);
result
}
#[tauri::command]
+33
View File
@@ -442,6 +442,12 @@ impl AppState {
content_watchers: Mutex::new(content_watchers),
_scratch_watcher: scratch_watcher,
});
{
let weak = Arc::downgrade(&project);
project.lsp.set_candidates_hook(Box::new(move |q, cancel| {
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
}));
}
self.projects.write().insert(window.to_owned(), project.clone());
Ok(project)
}
@@ -609,6 +615,33 @@ impl Project {
out
}
/// Project files of `q.languages` under `q.root` containing `q.word` as a whole
/// word (unsaved buffers included; excluded roots, ignored names and
/// `.gitignore`d files skipped): the files a references search opens on its server.
pub fn reference_candidates(
&self,
q: &ide_lsp::CandidateQuery,
cancel: &CancellationToken,
) -> ide_lsp::CandidateFiles {
if !q.root.starts_with(&self.root) {
return ide_lsp::CandidateFiles::default();
}
let query = ide_search::WordQuery {
word: q.word.clone(),
dir: q.root.clone(),
exclude: self.search_excludes(),
limit: q.limit,
};
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None));
match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
Err(e) => {
tracing::debug!("reference candidates: {}", e.message);
ide_lsp::CandidateFiles::default()
}
}
}
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
pub fn exclude_globs(&self) -> Vec<String> {
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
+18 -1
View File
@@ -287,7 +287,12 @@ export const commands = {
* language server returned a non-`file:` URI for. Cached per project.
*/
lspVirtualDocument: (uri: string) => typedError<VirtualDocument, IdeError>(__TAURI_INVOKE("lsp_virtual_document", { uri })),
lspReferences: (doc: DocId, pos: Pos, includeDeclaration: boolean) => typedError<Location[], IdeError>(__TAURI_INVOKE("lsp_references", { doc, pos, includeDeclaration })),
/**
* References of the symbol at `pos`. `project_wide` also searches project files
* outside the language server's analysis scope; bulk scans over many symbols
* leave it off. 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 })),
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 })),
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })),
@@ -4175,6 +4180,18 @@ export type RecentProject = {
branch: string | null,
};
export type References = {
locations: Location[],
incomplete: ReferencesIncomplete | null,
};
/** Why a references search may have missed usages. */
export type ReferencesIncomplete =
/** A language server was still indexing. */
"indexing" |
/** More project files mention the name than were checked. */
"tooManyFiles";
export type Remote = {
name: string,
/** Fetch URL. */
+4 -2
View File
@@ -9,7 +9,7 @@ import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, type CodeLens } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { commands, type OutlineItem, type SymbolKind } from "@/platform/ipc";
import { commands, nextRequestId, type OutlineItem, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { posToOffset } from "./positions";
@@ -166,7 +166,9 @@ class UsagesLensPlugin {
await Promise.all(
todo.slice(i, i + CONCURRENCY).map(async (t) => {
try {
const refs = await lspRequest(() => commands.lspReferences(doc.doc, { line: t.line, col: t.col }, false));
const { locations: refs } = await lspRequest(() =>
commands.lspReferences(nextRequestId(), doc.doc, { line: t.line, col: t.col }, false, true),
);
this.cache.set(key(t), refs.length);
} catch (e) {
if (isUnavailable(e)) unavailable = true;
+4 -1
View File
@@ -16,7 +16,10 @@ let outline: Answer<OutlineItem[]> = ok([]);
let references: Answer<Location[]> = ok([]);
const lspGoto = vi.fn(async () => goto());
const docOutline = vi.fn(async () => outline());
const lspReferences = vi.fn(async () => references());
const lspReferences = vi.fn(async () => {
const r = references();
return r.status === "ok" ? { status: "ok" as const, data: { locations: r.data, incomplete: null } } : r;
});
const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] }));
const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null }));
const docOpen = vi.fn(async (path: string) => ({ status: "ok", data: { id: 99, path } }));
+4 -3
View File
@@ -13,7 +13,7 @@
import type { EditorView } from "@codemirror/view";
import type { Text } from "@codemirror/state";
import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings";
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { basename } from "@/platform/project";
import { editorsFor, whenSynced, type EditorDocContext } from "@/editor/api";
@@ -252,7 +252,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom
comparePos(range.start, target.range.end) > 0;
if (target.local)
return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
const refs = await lspRequest(() => commands.lspReferences(ctx.doc, target.at, false));
const { locations: refs } = await lspRequest(() => commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true));
return refs.filter((l) => outside(l.path, l.range));
}
@@ -517,7 +517,8 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal)
if (signal?.aborted) return;
let refs: Location[];
try {
refs = await lspRequest(() => commands.lspReferences(doc, item.selection_range.start, false));
// Every symbol of every file: the server's own scope (a project search each would be too slow).
refs = (await lspRequest(() => commands.lspReferences(nextRequestId(), doc, item.selection_range.start, false, false))).locations;
} catch (e) {
if (isUnavailable(e)) {
reason = errorText(e);
+12 -2
View File
@@ -24,7 +24,7 @@ vi.mock("@/platform/ipc", async (orig) => {
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
const { _addEditor } = await import("@/editor/api");
const { fetchUsages } = await import("./usages");
const { fetchUsages, incompleteNote } = await import("./usages");
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
@@ -39,11 +39,21 @@ beforeEach(() => {
describe("Find Usages fallback", () => {
it("uses the server when it answers, even with nothing", async () => {
cmd.lspReferences.mockReturnValueOnce(ok([]));
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null }));
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null });
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("searches project-wide under the given request and says when results may be incomplete", async () => {
const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" }));
const res = await fetchUsages(caret, 42 as never);
expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true);
expect(res.groups[0]!.usages.length).toBe(1);
expect(res.note).toBe(incompleteNote("indexing", "x"));
expect(incompleteNote("tooManyFiles", "x")).toBe("May be incomplete: too many files mention 'x' to search them all");
});
it("lists a certain local's references when the server is not ready", async () => {
cmd.syntaxLocals.mockReturnValueOnce(
ok({
+47 -9
View File
@@ -1,7 +1,7 @@
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
import { create } from "zustand";
import type { Location } from "@/bindings";
import { commands, isCancelled } from "@/platform/ipc";
import type { Location, ReferencesIncomplete, RequestId } from "@/bindings";
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { toolWindowService } from "@/workbench/contributions";
import {
caretContext,
@@ -119,6 +119,7 @@ export function pinUsageTab(id: number, pinned: boolean) {
}
export function closeUsageTab(id: number) {
cancelSearch(id);
useUsages.setState((s) => {
const i = s.tabs.findIndex((t) => t.id === id);
const tabs = s.tabs.filter((t) => t.id !== id);
@@ -153,15 +154,25 @@ interface FetchedUsages {
note: string | null;
}
/** Why the server's answer may miss usages, for the results' note. */
export function incompleteNote(reason: ReferencesIncomplete, word: string | null): string {
if (reason === "indexing") return "May be incomplete: the language server is still indexing";
return `May be incomplete: too many files mention ${word ? `'${word}'` : "the name"} to search them all`;
}
/**
* The server's references; when it is not ready or fails, the syntax fallback
* (an empty server answer is respected). Without a fallback the server error stands.
* 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
* answer is respected). Without a fallback the server error stands.
* Cancel with `commands.cancelRequest(request)`.
*/
export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise<FetchedUsages> {
let locs: Location[];
let note: string | null = null;
try {
locs = await lspRequest(() => commands.lspReferences(c.doc, { line: c.line, col: c.col }, false));
const refs = await lspRequest(() => commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true));
locs = refs.locations;
if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word);
} catch (e) {
if (isCancelled(e)) throw e;
let fb = null;
@@ -179,8 +190,22 @@ export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
return { groups: groupUsages(locs, files), note };
}
/** In-flight server requests: of tool window searches by search id, and of the popup. */
const inflight = new Map<number, RequestId>();
let popupRequest: RequestId | null = null;
function cancelSearch(id: number) {
const request = inflight.get(id);
if (request === undefined) return;
inflight.delete(id);
void commands.cancelRequest(request).catch(() => {});
}
/** The window switched projects: results (and in-flight searches) of the previous one are dropped. */
export function resetUsages() {
for (const id of [...inflight.keys()]) cancelSearch(id);
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
popupRequest = null;
useUsages.setState({ tabs: [], active: null });
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null });
}
@@ -195,15 +220,22 @@ export async function findUsages() {
const id = ++searchSeq;
const base = { id, symbol: c.word, rerun: () => void run(id) };
const loading = { ...base, loading: true, groups: [], error: null, note: null };
if (prev !== null) cancelSearch(prev);
if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading);
toolWindowService().show("usages");
const request = nextRequestId();
inflight.set(id, request);
try {
const { groups, note } = await fetchUsages(c);
const { groups, note } = await fetchUsages(c, request);
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note });
} catch (e) {
// Cancelled here (closed tab, rerun): a newer state already replaced it.
if (isCancelled(e) && !inflight.has(id)) return;
const message = e instanceof Error ? e.message : String(e);
if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return;
reportError(e);
} finally {
if (inflight.get(id) === request) inflight.delete(id);
}
};
await run(null);
@@ -232,11 +264,15 @@ export async function showUsagesPopup() {
hint("Open a file to show usages");
return;
}
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
const request = nextRequestId();
popupRequest = request;
try {
const { groups, note } = await fetchUsages(c);
const { groups, note } = await fetchUsages(c, request);
const total = usageCount(groups);
if (!total) {
hint(c.word ? `No usages of '${c.word}' found` : "No usages found");
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
hint(note ? `${none}. ${note}` : none);
return;
}
if (total === 1) {
@@ -249,5 +285,7 @@ export async function showUsagesPopup() {
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
} catch (e) {
reportError(e);
} finally {
if (popupRequest === request) popupRequest = null;
}
}
@@ -6,7 +6,7 @@ const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line:
vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()),
caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }),
lspRequest: async () => [loc],
lspRequest: async () => ({ locations: [loc], incomplete: null }),
readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]),
openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`),
hint: (text: string) => void calls.push(`hint ${text}`),