fix(lsp): find Go usages without reopening gopls' files, and in other modules (#1909)

Finding usages of a common Go name such as New opened every project file that mentions it and asked gopls again, which took up to seconds and found nothing more: gopls answers from the view of the file it is asked about, and opened files only get views of their own. Go searches now skip that widening.

Usages in other modules of the project that use the declaration's module through a local replace (a multi-module repository without go.work) are found the way GoLand finds them: each mention of the name there is resolved in its own module's view, and those resolving to the declaration are added after gopls' own answer. Rename edits them too.

Supersedes #1749.

Co-authored-by: tiube00001 <[email protected]>
This commit is contained in:
l0ng-aiandtiube00001 authored and GitHub committed 2026-11-07 11:29:53 +08:00
1 parent 68342b32bf
commit 5084388a68
4 files changed
+474 -4

No files matched your search

+237
View File
@@ -0,0 +1,237 @@
//! Go modules a gopls search does not reach.
//!
//! gopls answers a search from the view of the file it is asked about: the
//! modules of a `go.work`, or that file's own module. Opening more files does
//! not widen it (each gets a view of its own), so the generic scope widening
//! is skipped for Go. Usages in other modules of the project, which use the
//! declaration's module through a local `replace` (a multi-module repository
//! without `go.work`), are found as GoLand finds them: their mentions of the
//! name are resolved one by one in their own module's view.
use ide_core::{Pos, Range};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The files of `candidates` in modules the search at `origin` does not cover and
/// that can refer to `declaration`: its own module, or one replacing it with its directory.
pub(crate) fn other_module_files(origin: &Path, declaration: &Path, candidates: Vec<PathBuf>) -> Vec<PathBuf> {
let mut modules = ModuleRoots::default();
let (Some(origin_module), Some(declaring)) = (modules.of(origin), modules.of(declaration)) else {
return Vec::new();
};
let Some(declaring_path) = read_module_path(&declaring) else { return Vec::new() };
let mut covered: HashSet<PathBuf> = workspace_modules(&origin_module).into_iter().collect();
covered.insert(origin_module);
let mut uses: HashMap<PathBuf, bool> = HashMap::new();
candidates
.into_iter()
.filter(|file| {
let Some(module) = modules.of(file) else { return false };
if covered.contains(&module) {
return false;
}
*uses.entry(module.clone()).or_insert_with(|| {
module == declaring
|| std::fs::read_to_string(module.join("go.mod")).is_ok_and(|text| {
local_replacements(&text, &module)
.iter()
.any(|(path, dir)| *path == declaring_path && *dir == declaring)
})
})
})
.collect()
}
/// Whole-word occurrences of `word` in `text`, as ranges in UTF-16 columns.
pub(crate) fn word_ranges(text: &str, word: &str) -> Vec<Range> {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = Vec::new();
for (line_no, line) in text.lines().enumerate() {
let code = line.split("//").next().unwrap_or(line);
for (at, _) in code.match_indices(word) {
let before = code[..at].chars().next_back();
let after = code[at + word.len()..].chars().next();
if before.is_some_and(ident) || after.is_some_and(ident) {
continue;
}
let col = code[..at].encode_utf16().count() as u32;
let len = word.encode_utf16().count() as u32;
out.push(Range::new(Pos::new(line_no as u32, col), Pos::new(line_no as u32, col + len)));
}
}
out
}
/// Nearest enclosing `go.mod` directory, cached per directory.
#[derive(Default)]
struct ModuleRoots(HashMap<PathBuf, Option<PathBuf>>);
impl ModuleRoots {
fn of(&mut self, file: &Path) -> Option<PathBuf> {
let dir = file.parent()?.to_path_buf();
self.dir(&dir)
}
fn dir(&mut self, dir: &Path) -> Option<PathBuf> {
if let Some(found) = self.0.get(dir) {
return found.clone();
}
let found =
if dir.join("go.mod").is_file() { Some(dir.to_path_buf()) } else { dir.parent().and_then(|p| self.dir(p)) };
self.0.insert(dir.to_path_buf(), found.clone());
found
}
}
fn read_module_path(module: &Path) -> Option<String> {
let text = std::fs::read_to_string(module.join("go.mod")).ok()?;
text.lines().find_map(|line| Some(unquote(line.trim().strip_prefix("module ")?.trim())))
}
/// The modules a `go.work` above `module` uses, as gopls loads them together.
fn workspace_modules(module: &Path) -> Vec<PathBuf> {
let Some(work) = module.ancestors().map(|d| d.join("go.work")).find(|p| p.is_file()) else { return Vec::new() };
let Ok(text) = std::fs::read_to_string(&work) else { return Vec::new() };
let base = work.parent().unwrap_or(Path::new(""));
directives(&text, "use").into_iter().map(|dir| normalize(&base.join(dir))).collect()
}
/// `replace <module> [version] => <local directory>` entries, directories resolved against `base`.
fn local_replacements(go_mod: &str, base: &Path) -> Vec<(String, PathBuf)> {
directives(go_mod, "replace")
.into_iter()
.filter_map(|entry| {
let (from, to) = entry.split_once("=>")?;
let from = unquote(from.split_whitespace().next()?);
let to = unquote(to.split_whitespace().next()?);
let local = to.starts_with("./")
|| to.starts_with("../")
|| Path::new(&to).is_absolute()
|| to == "."
|| to == "..";
local.then(|| (from, normalize(&base.join(to))))
})
.collect()
}
/// The arguments of `keyword` directives, single-line and block form.
fn directives(text: &str, keyword: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_block = false;
for line in text.lines() {
let line = line.split("//").next().unwrap_or("").trim();
if in_block {
if line == ")" {
in_block = false;
} else if !line.is_empty() {
out.push(line.to_owned());
}
} else if let Some(rest) = line.strip_prefix(keyword)
&& rest.starts_with([' ', '\t', '('])
{
let rest = rest.trim();
if rest == "(" {
in_block = true;
} else if !rest.is_empty() {
out.push(rest.to_owned());
}
}
}
out
}
fn unquote(s: &str) -> String {
s.trim_matches(|c| c == '"' || c == '`').to_owned()
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, text: &str) -> PathBuf {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
}
fn names(root: &Path, files: Vec<PathBuf>) -> Vec<String> {
files.iter().map(|f| f.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")).collect()
}
#[test]
fn modules_replacing_the_declarations_module_are_searched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.mod", "module example.com/app\n");
let origin = write(root, "app.go", "package app\nfunc New() {}\n");
write(root, "example/a/go.mod", "module a\n\nreplace example.com/app => ../..\n");
write(root, "example/b/go.mod", "module b\n\nreplace (\n\texample.com/app v1.0.0 => \"../../\"\n)\n");
write(root, "example/c/go.mod", "module c\n\nrequire example.com/app v1.0.0\n");
write(root, "example/d/go.mod", "module d\n\nreplace example.com/app => github.com/fork/app v1.0.0\n");
let candidates = vec![
write(root, "same.go", "package app\n"),
write(root, "example/a/main.go", "package main\n"),
write(root, "example/b/main.go", "package main\n"),
write(root, "example/c/main.go", "package main\n"),
write(root, "example/d/main.go", "package main\n"),
];
assert_eq!(
names(root, other_module_files(&origin, &origin, candidates)),
["example/a/main.go", "example/b/main.go"]
);
}
#[test]
fn go_work_modules_are_covered_by_the_first_search() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.work", "go 1.22\n\nuse (\n\t./lib // shared\n\t./app\n)\n");
write(root, "lib/go.mod", "module example.com/lib\n");
write(root, "app/go.mod", "module example.com/app\n\nreplace example.com/lib => ../lib\n");
write(root, "tool/go.mod", "module example.com/tool\n\nreplace example.com/lib => ../lib\n");
let declaration = write(root, "lib/lib.go", "package lib\n");
let candidates =
vec![write(root, "app/main.go", "package main\n"), write(root, "tool/main.go", "package main\n")];
assert_eq!(names(root, other_module_files(&declaration, &declaration, candidates)), ["tool/main.go"]);
}
#[test]
fn a_usage_site_in_another_module_searches_the_declaring_module() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(root, "go.mod", "module example.com/app\n");
let declaration = write(root, "app.go", "package app\n");
write(root, "cmd/go.mod", "module cmd\n\nreplace example.com/app => ../\n");
let origin = write(root, "cmd/main.go", "package main\n");
let candidates = vec![write(root, "other.go", "package app\n"), write(root, "cmd/util.go", "package main\n")];
assert_eq!(names(root, other_module_files(&origin, &declaration, candidates)), ["other.go"]);
}
#[test]
fn word_ranges_are_whole_words_outside_line_comments() {
let text = "x := app.New() // New here\nNewer := New\n\tvar é = New()\n";
assert_eq!(
word_ranges(text, "New"),
[
Range::new(Pos::new(0, 9), Pos::new(0, 12)),
Range::new(Pos::new(1, 9), Pos::new(1, 12)),
Range::new(Pos::new(2, 9), Pos::new(2, 12)),
]
);
}
}
+29 -2
View File
@@ -13,6 +13,7 @@ mod convert;
mod external_project;
mod file_ops;
mod file_watch;
mod go_scope;
mod handler;
mod inspect;
pub mod jdtls;
@@ -53,7 +54,7 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use server::{Conn, Running, Server};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak};
@@ -1688,6 +1689,19 @@ impl LspManager {
}
let mut v = out.finish(References::default())?;
convert::dedup_locations(&mut v.locations);
if project_wide && let Some(t) = targets.first().filter(|_| snap.language.as_str() == "go") {
// Usages in other modules take a view per module: those in reach come first.
if let Some(partial) = partial
&& !v.locations.is_empty()
{
partial(v.clone());
}
v.locations.extend(self.go_other_module_references(&snap, t, pos, &cancel).await);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
convert::dedup_locations(&mut v.locations);
}
Ok(v)
}
@@ -2120,7 +2134,20 @@ impl LspManager {
};
let w = self.widened(snap, t, pos, cancel, query, files).await?;
// The second edit covers the first: its files stay analysed.
Ok(Renamed { edit: w.second.unwrap_or(w.first), incomplete: w.incomplete })
let mut edit = w.second.unwrap_or(w.first);
let mut more: BTreeMap<PathBuf, Vec<TextEdit>> = BTreeMap::new();
for l in self.go_other_module_references(snap, t, pos, cancel).await {
more.entry(l.path.to_path_buf())
.or_default()
.push(TextEdit { range: l.range, new_text: new_name.to_owned() });
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let files =
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
Ok(Renamed { edit, incomplete: w.incomplete })
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
+95 -2
View File
@@ -10,9 +10,11 @@
//! closed again. Diagnostics the server publishes for them are dropped.
use crate::server::{Running, Server};
use crate::text::DocText;
use crate::types::ReferencesIncomplete;
use crate::{DocSnap, LspManager, Target};
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Pos};
use futures::StreamExt;
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos};
use lsp_types as lsp;
use serde_json::json;
use std::collections::HashSet;
@@ -110,7 +112,9 @@ impl LspManager {
Fut: Future<Output = IdeResult<R>>,
{
let busy = t.server.is_busy();
let word = search_word(snap, pos).filter(|_| t.caps().open_close);
// gopls searches the view of the file asked about; files opened for it only get views of
// their own (see `go_scope`).
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
let candidates = async {
match word {
Some(w) => self.candidates(t, w, cancel).await,
@@ -162,6 +166,95 @@ impl LspManager {
}
}
/// Go: usages of the symbol at `pos` in the project's other modules that use its module
/// through a local `replace`, which the search at `pos` does not reach (see `go_scope`).
/// Each mention of the name there is resolved in its own module's view.
pub(crate) async fn go_other_module_references(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: &CancellationToken,
) -> Vec<Location> {
if snap.language.as_str() != "go" || !t.caps().open_close {
return Vec::new();
}
let Some(word) = search_word(snap, pos) else { return Vec::new() };
let declarations: Vec<Location> = self
.goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None)
.await
.unwrap_or_default()
.into_iter()
.filter(|l| l.uri.is_none())
.collect();
let Some(declaration) = declarations.first() else { return Vec::new() };
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return Vec::new() };
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
let files = tokio::task::spawn_blocking(move || {
crate::go_scope::other_module_files(&origin, &declared, candidates.files)
.into_iter()
.filter_map(|f| {
let text = std::fs::read_to_string(&f).ok()?;
let ranges = crate::go_scope::word_ranges(&text, &word);
(!ranges.is_empty()).then(|| (f, DocText::new(&text), ranges))
})
.collect::<Vec<_>>()
})
.await
.unwrap_or_default();
if files.is_empty() || cancel.is_cancelled() {
return Vec::new();
}
// Open, each file gives gopls a view of its module to resolve the mentions in.
let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await;
let probes: Vec<(PathBuf, Arc<DocText>, ide_core::Range)> = files
.into_iter()
.flat_map(|(path, text, ranges)| {
let text = Arc::new(text);
ranges.into_iter().map(move |r| (path.clone(), text.clone(), r))
})
.collect();
let declarations = &declarations;
let found: Vec<Option<Location>> = futures::stream::iter(probes)
.map(|(path, text, range)| async move {
let params = json!({
"textDocument": { "uri": crate::uri::path_to_uri_string(&path) },
"position": text.ide_to_lsp(t.enc(), range.start),
});
let r = crate::loading::while_loading(
&t.server,
Some(cancel),
crate::loading::LOADING_WAIT,
crate::goto_is_empty,
|| {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
"textDocument/definition",
params.clone(),
Some(cancel),
crate::DEFAULT_TIMEOUT,
)
},
)
.await;
let targets = match r {
Ok(Some(r)) => crate::convert::goto_response(&self.conv(t.enc()), r),
_ => return None,
};
let same = targets
.iter()
.any(|l| declarations.iter().any(|d| d.path == l.path && d.range.start == l.range.start));
same.then(|| Location { path: path.into(), range, uri: None })
})
.buffer_unordered(8)
.collect()
.await;
if cancel.is_cancelled() {
return Vec::new();
}
found.into_iter().flatten().collect()
}
/// Opens `files` on `t` (read from disk) unless the user has them open;
/// they are closed when the returned guard drops. Files too large or not
/// readable as text are left out and flagged on the guard.
+113
View File
@@ -2930,6 +2930,119 @@ async fn references_open_unopened_files_that_mention_the_name() {
assert_eq!(seen.lock().len(), 2);
}
/// A Go project: the module at the root, `tools` replacing it with `../` and `other` requiring a
/// published copy. The mock answers a definition in `tools` like gopls in that module's view.
fn go_modules_harness() -> (Harness, Arc<MockState>, PathBuf, PathBuf, PathBuf) {
let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]);
let s = h.launcher.state("gopls");
let write = |rel: &str, text: &str| {
let path = h.root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
path
};
write("go.mod", "module example.com/app\n");
write("tools/go.mod", "module example.com/tools\n\nreplace example.com/app => ../\n");
write("other/go.mod", "module example.com/other\n\nrequire example.com/app v1.0.0\n");
let same = write("same.go", "package app\nvar x = New()\n");
// Line 1 uses the declaration; line 2 is a local `New` of the tools module.
let tools = write("tools/gen.go", "package tools\nvar a = app.New()\nfunc New() {}\n");
write("other/o.go", "package other\nvar b = app.New()\n");
let decl = h.root.join("a.go");
let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go")];
h.mgr.set_candidates_hook(Box::new(move |_, _| ide_lsp::CandidateFiles {
files: candidates.clone(),
limit_hit: false,
}));
let at = |line: u32, col: u32| json!({ "start": { "line": line, "character": col }, "end": { "line": line, "character": col + 3 } });
let (decl_uri, same_uri, tools_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools));
*s.responder.lock() = Some(Arc::new(move |_, method, params| {
let uri = params["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
let line = params["position"]["line"].as_u64().unwrap_or(0);
match method {
// The view of the root module: the declaration and its usage in same.go.
"textDocument/references" => Some(json!([
{ "uri": decl_uri, "range": at(0, 5) },
{ "uri": same_uri, "range": at(1, 8) },
])),
"textDocument/rename" => Some(json!({ "changes": {
decl_uri.clone(): [{ "range": at(0, 5), "newText": "Make" }],
same_uri.clone(): [{ "range": at(1, 8), "newText": "Make" }],
} })),
"textDocument/definition" if uri == decl_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })),
"textDocument/definition" if uri == tools_uri && line == 1 => {
Some(json!({ "uri": decl_uri, "range": at(0, 5) }))
}
"textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })),
_ => None,
}
}));
(h, s, decl, same, tools)
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_resolve_other_modules_that_replace_the_declarations_module() {
let (h, s, decl, same, tools) = go_modules_harness();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", ServerStatus::Ready).await;
eventually(|| s.docs.lock().len() == 1).await;
let partials = Arc::new(Mutex::new(Vec::new()));
let seen = partials.clone();
let partial = move |r: ide_lsp::types::References| seen.lock().push(r.locations.len());
let refs = h
.mgr
.references_streaming(doc, Pos::new(0, 5), true, true, true, Default::default(), Some(&partial))
.await
.unwrap();
let mut found: Vec<(PathBuf, u32)> =
refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect();
found.sort();
let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1)];
want.sort();
assert_eq!(found, want);
// Those in gopls' reach arrive first.
assert_eq!(*partials.lock(), [2]);
// Only the module replacing the declaration's module was opened, never the root module's file.
let opened = uris(&s.notified("textDocument/didOpen"));
assert!(opened.contains(&uri_of(&tools)));
assert!(!opened.contains(&uri_of(&same)));
assert!(!opened.iter().any(|u| u.ends_with("other/o.go")));
assert_eq!(s.requested("textDocument/references").len(), 1);
let renamed = h.mgr.rename(doc, Pos::new(0, 5), "Make".into(), Default::default()).await.unwrap();
let mut edited: Vec<(PathBuf, Vec<u32>)> = renamed
.edit
.files
.iter()
.filter_map(|f| match f {
ide_core::FileEdit::Edit { path, edits, .. } => {
Some((path.to_path_buf(), edits.iter().map(|e| e.range.start.line).collect()))
}
_ => None,
})
.collect();
edited.sort();
let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1])];
want.sort();
assert_eq!(edited, want);
}
#[tokio::test(flavor = "multi_thread")]
async fn go_searches_open_nothing_when_no_other_module_uses_the_declaration() {
let (h, s, _, same, _) = go_modules_harness();
std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap();
let (doc, _) = h.open("a.go", "func New() {}\n");
h.wait_status("gopls", 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!(uris(&s.notified("textDocument/didOpen")).len(), 1, "only a.go");
assert!(s.requested("textDocument/definition").len() <= 1);
assert!(!uris(&s.notified("textDocument/didOpen")).contains(&uri_of(&same)));
}
#[tokio::test(flavor = "multi_thread")]
async fn references_never_reopen_or_close_the_users_files() {
let h = harness(&[("mock", incremental_caps())]);