diff --git a/crates/ide-lsp/src/go_scope.rs b/crates/ide-lsp/src/go_scope.rs index 09267d814..663dd5752 100644 --- a/crates/ide-lsp/src/go_scope.rs +++ b/crates/ide-lsp/src/go_scope.rs @@ -1,20 +1,23 @@ -//! Go modules a gopls search does not reach. +//! Go files 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. +//! modules of a `go.work`, or that file's own module, built for the host's +//! GOOS/GOARCH without extra tags. Opening more files does not widen it (each +//! gets a view of its own), so the generic scope widening is skipped for Go. +//! Usages it leaves out are found as GoLand finds them, by resolving each +//! mention of the name in its own file's view: files of other modules that use +//! the declaration's module through a local `replace` (a multi-module +//! repository without `go.work`), and files whose build constraints exclude +//! them from the default build (`_windows.go`, `//go:build`). 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 { +/// The files of `candidates` the search at `origin` does not cover and that can refer to +/// `declaration`: those of the covered modules excluded from their default build, and those +/// of the declaration's own module or one replacing it with its directory when not covered. +pub(crate) fn files_beyond_search(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(); @@ -27,8 +30,9 @@ pub(crate) fn other_module_files(origin: &Path, declaration: &Path, candidates: .into_iter() .filter(|file| { let Some(module) = modules.of(file) else { return false }; + // The covered modules share the search's view: only their excluded files are missed. if covered.contains(&module) { - return false; + return !in_default_build(file); } *uses.entry(module.clone()).or_insert_with(|| { module == declaring @@ -42,6 +46,108 @@ pub(crate) fn other_module_files(origin: &Path, declaration: &Path, candidates: .collect() } +/// Whether gopls' default view builds `file`: no build constraint other than the host's +/// own GOOS/GOARCH file name suffix. +fn in_default_build(file: &Path) -> bool { + let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = name.strip_suffix(".go").unwrap_or(&name); + let stem = stem.strip_suffix("_test").unwrap_or(stem); + let parts: Vec<&str> = stem.split('_').collect(); + // `name_GOOS`, `name_GOARCH`, `name_GOOS_GOARCH`; a file named just `linux.go` has no constraint. + let host = |part: &&str| *part == host_goos() || *part == host_goarch(); + if parts.len() > 1 { + let last = parts[parts.len() - 1]; + if (GOOS.contains(&last) || GOARCH.contains(&last)) && !host(&last) { + return false; + } + if parts.len() > 2 && GOARCH.contains(&last) { + let os = parts[parts.len() - 2]; + if GOOS.contains(&os) && !host(&os) { + return false; + } + } + } + std::fs::read_to_string(file).is_ok_and(|text| !constrained_header(&text)) +} + +/// A `//go:build` or `// +build` line before the package clause. +fn constrained_header(text: &str) -> bool { + for line in text.lines() { + let line = line.trim(); + if line.starts_with("package ") { + return false; + } + if line.starts_with("//go:build") || line.starts_with("// +build") { + return true; + } + } + false +} + +fn host_goos() -> &'static str { + match std::env::consts::OS { + "macos" => "darwin", + os => os, + } +} + +fn host_goarch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + "x86" => "386", + "powerpc64" => "ppc64", + arch => arch, + } +} + +const GOOS: &[&str] = &[ + "aix", + "android", + "darwin", + "dragonfly", + "freebsd", + "hurd", + "illumos", + "ios", + "js", + "linux", + "nacl", + "netbsd", + "openbsd", + "plan9", + "solaris", + "wasip1", + "windows", + "zos", +]; +const GOARCH: &[&str] = &[ + "386", + "amd64", + "amd64p32", + "arm", + "armbe", + "arm64", + "arm64be", + "loong64", + "mips", + "mipsle", + "mips64", + "mips64le", + "mips64p32", + "mips64p32le", + "ppc", + "ppc64", + "ppc64le", + "riscv", + "riscv64", + "s390", + "s390x", + "sparc", + "sparc64", + "wasm", +]; + /// 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 == '_'; @@ -185,14 +291,19 @@ mod tests { 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, "same_plan9.go", "package app\n"), + write(root, &format!("same_{}.go", host_goos()), "package app\n"), + write(root, "same_plan9_test.go", "package app\n"), + write(root, "tagged.go", "// Copyright\n\n//go:build integration\n\npackage app\n"), + write(root, "plan9.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"] + names(root, files_beyond_search(&origin, &origin, candidates)), + ["same_plan9.go", "same_plan9_test.go", "tagged.go", "example/a/main.go", "example/b/main.go"] ); } @@ -205,9 +316,15 @@ mod tests { 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"]); + let candidates = vec![ + write(root, "app/main.go", "package main\n"), + write(root, "app/main_plan9.go", "package main\n"), + write(root, "tool/main.go", "package main\n"), + ]; + assert_eq!( + names(root, files_beyond_search(&declaration, &declaration, candidates)), + ["app/main_plan9.go", "tool/main.go"] + ); } #[test] @@ -219,7 +336,7 @@ mod tests { 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"]); + assert_eq!(names(root, files_beyond_search(&origin, &declaration, candidates)), ["other.go"]); } #[test] diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 5bfed1714..0eebfed48 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -1696,7 +1696,7 @@ impl LspManager { { partial(v.clone()); } - v.locations.extend(self.go_other_module_references(&snap, t, pos, &cancel).await); + v.locations.extend(self.go_mentions_beyond_search(&snap, t, pos, &cancel, false).await.locations); if cancel.is_cancelled() { return Err(IdeError::cancelled()); } @@ -2136,7 +2136,7 @@ impl LspManager { // The second edit covers the first: its files stay analysed. 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 { + for l in self.go_mentions_beyond_search(snap, t, pos, cancel, false).await.locations { more.entry(l.path.to_path_buf()) .or_default() .push(TextEdit { range: l.range, new_text: new_name.to_owned() }); @@ -2592,6 +2592,15 @@ impl LspManager { } HierarchyDirection::Outgoing => (query().await?, None), }; + let mut out = out; + if direction == HierarchyDirection::Incoming { + for caller in self.go_mentions_beyond_search(&snap, t, pos, cancel, true).await.callers { + match out.iter_mut().find(|i| i.path == caller.path && i.range == caller.range) { + Some(item) => item.from_ranges.extend(caller.from_ranges), + None => out.push(caller), + } + } + } if cancel.is_cancelled() { return Err(IdeError::cancelled()); } diff --git a/crates/ide-lsp/src/scope.rs b/crates/ide-lsp/src/scope.rs index 8b8227111..281f40c0b 100644 --- a/crates/ide-lsp/src/scope.rs +++ b/crates/ide-lsp/src/scope.rs @@ -11,7 +11,7 @@ use crate::server::{Running, Server}; use crate::text::DocText; -use crate::types::ReferencesIncomplete; +use crate::types::{HierarchyItem, ReferencesIncomplete}; use crate::{DocSnap, LspManager, Target}; use futures::StreamExt; use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId, Location, Pos}; @@ -53,6 +53,14 @@ pub struct CandidateFiles { /// blocking thread and should stop early when cancelled. pub type CandidateFn = dyn Fn(&CandidateQuery, &CancellationToken) -> CandidateFiles + Send + Sync; +/// What a Go search adds beyond gopls' own answer. +#[derive(Default)] +pub(crate) struct GoMentions { + pub locations: Vec, + /// The declarations containing them, as incoming calls (when asked for). + pub callers: Vec, +} + /// A reference-based answer and, when files were opened for it, the second one. pub(crate) struct Widened { pub first: R, @@ -166,20 +174,21 @@ 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( + /// Go: usages of the symbol at `pos` that the search at `pos` does not reach (see + /// `go_scope`), each mention of the name resolved in its own file's view; with `callers`, + /// also the declarations containing them, as incoming calls. + pub(crate) async fn go_mentions_beyond_search( &self, snap: &DocSnap, t: &Target, pos: Pos, cancel: &CancellationToken, - ) -> Vec { + callers: bool, + ) -> GoMentions { if snap.language.as_str() != "go" || !t.caps().open_close { - return Vec::new(); + return GoMentions::default(); } - let Some(word) = search_word(snap, pos) else { return Vec::new() }; + let Some(word) = search_word(snap, pos) else { return GoMentions::default() }; let declarations: Vec = self .goto_in(snap, std::slice::from_ref(t), "textDocument/definition", pos, Some(cancel), None) .await @@ -187,11 +196,11 @@ impl LspManager { .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 Some(declaration) = declarations.first() else { return GoMentions::default() }; + let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() }; 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) + crate::go_scope::files_beyond_search(&origin, &declared, candidates.files) .into_iter() .filter_map(|f| { let text = std::fs::read_to_string(&f).ok()?; @@ -203,14 +212,17 @@ impl LspManager { .await .unwrap_or_default(); if files.is_empty() || cancel.is_cancelled() { - return Vec::new(); + return GoMentions::default(); } - // Open, each file gives gopls a view of its module to resolve the mentions in. + // Open, each file gives gopls a view to resolve its mentions in (its module, its build). let _opened = self.open_transient(t, files.iter().map(|(f, _, _)| f.clone()).collect(), cancel).await; + let texts: Vec<(PathBuf, Arc)> = + files.iter().map(|(path, text, _)| (path.clone(), Arc::new(text.clone()))).collect(); let probes: Vec<(PathBuf, Arc, ide_core::Range)> = files .into_iter() - .flat_map(|(path, text, ranges)| { - let text = Arc::new(text); + .zip(&texts) + .flat_map(|((path, _, ranges), (_, text))| { + let text = text.clone(); ranges.into_iter().map(move |r| (path.clone(), text.clone(), r)) }) .collect(); @@ -249,10 +261,86 @@ impl LspManager { .buffer_unordered(8) .collect() .await; - if cancel.is_cancelled() { - return Vec::new(); + let locations: Vec = found.into_iter().flatten().collect(); + let mut out = GoMentions { callers: Vec::new(), locations }; + if callers && !cancel.is_cancelled() { + for (path, text) in &texts { + let here: Vec = + out.locations.iter().filter(|l| l.path == *path).map(|l| l.range).collect(); + if !here.is_empty() { + out.callers.extend(self.go_callers_in(t, path, text, &here, cancel).await); + } + } } - found.into_iter().flatten().collect() + if cancel.is_cancelled() { + return GoMentions::default(); + } + out + } + + /// The declarations of `path` containing `calls`, innermost first, each with its call sites. + async fn go_callers_in( + &self, + t: &Target, + path: &Path, + text: &DocText, + calls: &[ide_core::Range], + cancel: &CancellationToken, + ) -> Vec { + let params = json!({ "textDocument": { "uri": crate::uri::path_to_uri_string(path) } }); + let r: IdeResult> = + self.call(t, "textDocument/documentSymbol", params, Some(cancel), crate::DEFAULT_TIMEOUT).await; + let range = + |r: lsp::Range| ide_core::Range::new(text.lsp_to_ide(t.enc(), r.start), text.lsp_to_ide(t.enc(), r.end)); + // (name, kind, detail, range, selection range) of every declaration. + let mut symbols = Vec::new(); + match r { + Ok(Some(lsp::DocumentSymbolResponse::Nested(nested))) => { + let mut stack = nested; + while let Some(s) = stack.pop() { + symbols.push((s.name, s.kind, s.detail, range(s.range), range(s.selection_range))); + stack.extend(s.children.unwrap_or_default()); + } + } + #[allow(deprecated)] + Ok(Some(lsp::DocumentSymbolResponse::Flat(flat))) => { + for s in flat { + let r = range(s.location.range); + symbols.push((s.name, s.kind, None, r, r)); + } + } + _ => return Vec::new(), + } + let contains = + |outer: &ide_core::Range, inner: &ide_core::Range| outer.start <= inner.start && inner.end <= outer.end; + let mut items: Vec = Vec::new(); + for call in calls { + // The innermost function or method; a package-level declaration otherwise. + let callable = |k: lsp::SymbolKind| matches!(k, lsp::SymbolKind::FUNCTION | lsp::SymbolKind::METHOD); + let innermost = |pick: &dyn Fn(lsp::SymbolKind) -> bool| { + symbols + .iter() + .filter(|(_, kind, _, r, _)| pick(*kind) && contains(r, call)) + .min_by_key(|(_, _, _, r, _)| (r.end.line - r.start.line, r.end.col.saturating_sub(r.start.col))) + }; + let Some((name, kind, detail, r, selection)) = innermost(&callable).or_else(|| innermost(&|_| true)) else { + continue; + }; + match items.iter_mut().find(|i| i.range == *r && i.name == *name) { + Some(item) => item.from_ranges.push(*call), + None => items.push(HierarchyItem { + name: name.clone(), + kind: crate::convert::symbol_kind(*kind), + detail: detail.clone(), + path: path.to_path_buf().into(), + uri: None, + range: *r, + selection_range: *selection, + from_ranges: vec![*call], + }), + } + } + items } /// Opens `files` on `t` (read from disk) unless the user has them open; diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 2ffb03fa4..8deea1a2a 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2932,7 +2932,7 @@ async fn references_open_unopened_files_that_mention_the_name() { /// 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) { +fn go_modules_harness() -> (Harness, Arc, PathBuf, PathBuf, PathBuf, PathBuf) { let h = harness_for("go", "go", &[("gopls", incremental_caps())], &[]); let s = h.launcher.state("gopls"); let write = |rel: &str, text: &str| { @@ -2948,14 +2948,17 @@ fn go_modules_harness() -> (Harness, Arc, PathBuf, PathBuf, PathBuf) // 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"); + // Built for plan9 only: outside gopls' default view of the root module. + let plan9 = write("boot_plan9.go", "package app\nfunc boot() { New() }\n"); let decl = h.root.join("a.go"); - let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go")]; + let candidates = vec![same.clone(), tools.clone(), h.root.join("other/o.go"), plan9.clone()]; 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)); + let (decl_uri, same_uri, tools_uri, plan9_uri) = (uri_of(&decl), uri_of(&same), uri_of(&tools), uri_of(&plan9)); + let span = |line: u32, from: u32, to: u32| json!({ "start": { "line": line, "character": from }, "end": { "line": line, "character": to } }); *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); @@ -2974,15 +2977,30 @@ fn go_modules_harness() -> (Harness, Arc, PathBuf, PathBuf, PathBuf) Some(json!({ "uri": decl_uri, "range": at(0, 5) })) } "textDocument/definition" if uri == tools_uri => Some(json!({ "uri": tools_uri, "range": at(2, 5) })), + "textDocument/definition" if uri == plan9_uri => Some(json!({ "uri": decl_uri, "range": at(0, 5) })), + "textDocument/documentSymbol" if uri == tools_uri => Some(json!([ + { "name": "a", "kind": 13, "range": span(1, 0, 17), "selectionRange": span(1, 4, 5) }, + { "name": "New", "kind": 12, "range": span(2, 0, 13), "selectionRange": span(2, 5, 8) }, + ])), + "textDocument/documentSymbol" if uri == plan9_uri => Some(json!([ + { "name": "boot", "kind": 12, "range": span(1, 0, 20), "selectionRange": span(1, 5, 9) }, + ])), + "textDocument/prepareCallHierarchy" => Some( + json!([{ "name": "New", "kind": 12, "uri": decl_uri, "range": span(0, 0, 13), "selectionRange": at(0, 5) }]), + ), + "callHierarchy/incomingCalls" => Some(json!([{ + "from": { "name": "x", "kind": 13, "uri": same_uri, "range": span(1, 0, 13), "selectionRange": span(1, 4, 5) }, + "fromRanges": [at(1, 8)], + }])), _ => None, } })); - (h, s, decl, same, tools) + (h, s, decl, same, tools, plan9) } #[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 (h, s, decl, same, tools, plan9) = 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; @@ -2998,7 +3016,7 @@ async fn go_searches_resolve_other_modules_that_replace_the_declarations_module( 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)]; + let mut want = vec![(decl.clone(), 0), (same.clone(), 1), (tools.clone(), 1), (plan9.clone(), 1)]; want.sort(); assert_eq!(found, want); // Those in gopls' reach arrive first. @@ -3006,6 +3024,7 @@ async fn go_searches_resolve_other_modules_that_replace_the_declarations_module( // 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(&plan9))); 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); @@ -3023,15 +3042,42 @@ async fn go_searches_resolve_other_modules_that_replace_the_declarations_module( }) .collect(); edited.sort(); - let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1])]; + let mut want = vec![(decl, vec![0]), (same, vec![1]), (tools, vec![1]), (plan9, vec![1])]; want.sort(); assert_eq!(edited, want); } +#[tokio::test(flavor = "multi_thread")] +async fn go_incoming_calls_include_callers_beyond_the_search() { + let (h, s, _, same, tools, plan9) = 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 calls = + h.mgr.call_hierarchy(doc, Pos::new(0, 5), HierarchyDirection::Incoming, Default::default()).await.unwrap(); + let mut callers: Vec<(PathBuf, String, Vec)> = calls + .items + .iter() + .map(|i| (i.path.to_path_buf(), i.name.clone(), i.from_ranges.iter().map(|r| r.start.line).collect())) + .collect(); + callers.sort(); + let mut want = vec![ + (same, "x".to_owned(), vec![1]), + (tools, "a".to_owned(), vec![1]), + (plan9.clone(), "boot".to_owned(), vec![1]), + ]; + want.sort(); + assert_eq!(callers, want); + let boot = calls.items.iter().find(|i| i.name == "boot").unwrap(); + assert_eq!((boot.selection_range.start, boot.from_ranges[0].start), (Pos::new(1, 5), Pos::new(1, 14))); +} + #[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(); + let (h, s, _, same, _, plan9) = go_modules_harness(); std::fs::write(h.root.join("tools/go.mod"), "module example.com/tools\n").unwrap(); + std::fs::remove_file(&plan9).unwrap(); let (doc, _) = h.open("a.go", "func New() {}\n"); h.wait_status("gopls", ServerStatus::Ready).await; eventually(|| s.docs.lock().len() == 1).await;