diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 3e79608d..c5df7398 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -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 diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index a608317d..3ba57aa2 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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>, apply_edit_hook: RwLock>, config_hook: RwLock>, + candidates_hook: RwLock>>, shutting_down: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, @@ -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> { + /// 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 { 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>>( - 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> = + 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::>()) + }; + let files = + |v: &Vec| 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 = - 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 = 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 { 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 = 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 = 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 { + 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> = 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> = 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> = - 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> = + 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> = - 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> = + 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| { + 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) } diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs new file mode 100644 index 00000000..003b1758 --- /dev/null +++ b/crates/ide-lsp/src/scope.rs @@ -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, + pub limit: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct CandidateFiles { + pub files: Vec, + /// 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 { + pub first: R, + pub second: Option, + pub incomplete: Option, +} + +impl Widened { + /// 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 { + 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) { + *self.candidates_hook.write() = Some(Arc::from(hook)); + } + + async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option { + 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( + &self, + snap: &DocSnap, + t: &Target, + pos: Pos, + cancel: &CancellationToken, + query: F, + covered: impl Fn(&R) -> Vec, + ) -> IdeResult> + where + F: Fn() -> Fut, + Fut: Future>, + { + 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 = covered(&first).into_iter().chain([snap.path.clone()]).collect(); + let files: Vec = 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, 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::>() + }) + .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, + 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); + } + } + } +} diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 8025c0d2..04cbb9f8 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -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, +} + pub struct Progress { pub task: TaskId, pub title: String, @@ -80,6 +94,9 @@ pub struct Server { published: Mutex>, /// 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>, } 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) { diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 0499d7c7..4fb820e7 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -322,3 +322,20 @@ pub struct FileRenameEdits { /// Servers that failed; their references are not updated. pub errors: Vec, } + +/// 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, + pub incomplete: Option, +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 0a024516..0b26e084 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -27,9 +27,13 @@ struct MockState { /// uri → text as reconstructed by the mock from didOpen/didChange. docs: Mutex>, responses: Mutex>, + /// Computes a response from the request and the open documents (before `responses`). + responder: Mutex>, peer: Mutex>, } +type Responder = Arc Option + Send + Sync>; + impl MockState { fn notified(&self, method: &str) -> Vec { 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 { 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, ¶ms)) { + 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![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 { + 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) -> 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>> { + 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 = 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 { + 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) { + 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 = 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 = 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; +} diff --git a/crates/ide-search/src/lib.rs b/crates/ide-search/src/lib.rs index 8147272f..97515510 100644 --- a/crates/ide-search/src/lib.rs +++ b/crates/ide-search/src/lib.rs @@ -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}; diff --git a/crates/ide-search/src/words.rs b/crates/ide-search/src/words.rs new file mode 100644 index 00000000..ceb94a25 --- /dev/null +++ b/crates/ide-search/src/words.rs @@ -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, + /// Files returned at most. + pub limit: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WordFiles { + pub files: Vec, + /// 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>, + overlay: Overlay<'_>, + cancel: &CancellationToken, +) -> IdeResult { + 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()); + } +} diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 9a11ac0b..352e4c90 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -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> { - project!(window, state).lsp.references(doc, pos, include_declaration).await + project_wide: bool, +) -> IdeResult { + 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] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index eded2c92..0f2073ae 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -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 { exclude_globs(&self.settings.effective(), &self.detected_roots.read()) diff --git a/src/bindings.ts b/src/bindings.ts index cab0ea6c..d9149fa5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -287,7 +287,12 @@ export const commands = { * language server returned a non-`file:` URI for. Cached per project. */ lspVirtualDocument: (uri: string) => typedError(__TAURI_INVOKE("lsp_virtual_document", { uri })), - lspReferences: (doc: DocId, pos: Pos, includeDeclaration: boolean) => typedError(__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(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })), lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })), lspWorkspaceSymbols: (request: RequestId, query: string) => typedError(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })), lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError(__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. */ diff --git a/src/editor/lsp/codeLens.ts b/src/editor/lsp/codeLens.ts index 66832e8f..136ce82a 100644 --- a/src/editor/lsp/codeLens.ts +++ b/src/editor/lsp/codeLens.ts @@ -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; diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index d5e088c7..cfe0e53f 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -16,7 +16,10 @@ let outline: Answer = ok([]); let references: Answer = 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 } })); diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index ee6f6609..81ceb1f5 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -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); diff --git a/src/features/code-insight/syntaxUsages.test.ts b/src/features/code-insight/syntaxUsages.test.ts index 0755feaf..d675a015 100644 --- a/src/features/code-insight/syntaxUsages.test.ts +++ b/src/features/code-insight/syntaxUsages.test.ts @@ -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({ diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index fe967345..1791bcee 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -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 { +export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise { 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 { 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(); +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; } } diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx index 9a0276e8..d1fd513f 100644 --- a/src/features/code-insight/usagesPopup.test.tsx +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -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()), 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}`),