diff --git a/crates/ide-lsp/src/go_scope.rs b/crates/ide-lsp/src/go_scope.rs new file mode 100644 index 00000000..09267d81 --- /dev/null +++ b/crates/ide-lsp/src/go_scope.rs @@ -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) -> Vec { + 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 = workspace_modules(&origin_module).into_iter().collect(); + covered.insert(origin_module); + let mut uses: HashMap = 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 { + 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>); + +impl ModuleRoots { + fn of(&mut self, file: &Path) -> Option { + let dir = file.parent()?.to_path_buf(); + self.dir(&dir) + } + + fn dir(&mut self, dir: &Path) -> Option { + 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 { + 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 { + 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 [version] => ` 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 { + 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) -> Vec { + 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)), + ] + ); + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 0f7246cd..5bfed171 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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> = 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`) ---- diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs index bac2188f..8b822711 100644 --- a/crates/ide-lsp/src/scope.rs +++ b/crates/ide-lsp/src/scope.rs @@ -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>, { 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 { + 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 = 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::>() + }) + .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, 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> = 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>( + 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. diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index f6275bb3..2ffb03fa 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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, 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)> = 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())]);