Файли
l0ng-ai 8ec35c94ec 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
2026-10-27 17:41:20 +08:00

119 рядки
4.6 KiB
Rust

//! 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());
}
}