diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 8cf3a44d1..737db0e2f 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -28,7 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide let settings = settings.clone(); config = host .blocking(move || { - ide_sdk::go::debug_environment(&root, &settings, &mut config.env); + ide_sdk::go::configure_environment(&root, &settings, &mut config.env); ide_sdk::go::apply_to_debug( &mut config.env, &mut config.extra_json, diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 967095879..1e57b5576 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -1,7 +1,11 @@ //! Repository version lookups for manifest inspections and completion. use crate::{Host, rpc::Dispatcher}; use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; -use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go}; +use std::collections::BTreeMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::Command; struct CratesIo; impl DependencyVersionSource for CratesIo { @@ -21,6 +25,76 @@ impl DependencyVersionSource for CratesIo { } } +struct GoModules { + root: PathBuf, + env: BTreeMap, +} + +impl DependencyVersionSource for GoModules { + fn id(&self) -> &'static str { + "go-modules" + } + + fn cache_key(&self, package: &str) -> Option { + let mut hash = DefaultHasher::new(); + (&self.root, &self.env, package).hash(&mut hash); + Some(format!("{}-{:016x}", self.id(), hash.finish())) + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let search = + self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path); + let go = ide_core::tool_path::find_command("go", &search, &self.root) + .ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?; + // A partially edited go.mod is often invalid. Query outside it while + // keeping the SDK and proxy/private module configuration. + let dir = tempfile::tempdir()?; + let mut cmd = Command::new(go); + cmd.current_dir(dir.path()) + .env("PATH", search) + .envs(&self.env) + .env("GO111MODULE", "on") + .env("GOFLAGS", "") + .env("GOWORK", "off") + .env("GIT_TERMINAL_PROMPT", "0") + .args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]); + let query = cancel.child_token(); + // Fetches run on Host::blocking's Tokio pool, which keeps a runtime current. + let deadline = tokio::spawn({ + let query = query.clone(); + async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + query.cancel(); + } + }); + let output = ide_tools::run_streamed(cmd, &query, &|_| {}); + deadline.abort(); + Ok(go::listed_versions(&output?)) + } +} + +#[cfg(all(test, unix))] +#[path = "dependencies/go_tests.rs"] +mod go_tests; + +fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult { + let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { + workspace + .projects + .into_iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()) + }); + let settings = match &member { + Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(), + None => project.settings.effective(), + }; + let root = member.unwrap_or_else(|| project.root.clone()); + let mut env = BTreeMap::new(); + ide_sdk::go::configure_environment(&root, &settings, &mut env); + Ok(GoModules { root, env }) +} + /// `offset` limits a completion request to its version string; inspections omit it. pub async fn dependency_versions( host: &Host, @@ -31,11 +105,24 @@ pub async fn dependency_versions( ) -> IdeResult> { let project = host.project(window)?; let cache = host.dependency_versions.clone(); - let cancel = host.begin_request(request); + let config_dir = host.config_dir.clone(); + let cancel = host.begin_project_read_request(window, request); let result = host .blocking(move || { let snap = project.documents.snapshot(doc)?; - if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + if snap.large || snap.read_only { + return Ok(Vec::new()); + } + if snap.path.file_name().is_some_and(|name| name == "go.mod") { + let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else { + return Ok(Vec::new()); + }; + project.require_trust()?; + let source = go_source(&project, &config_dir, snap.path.as_ref())?; + let versions = cache.versions(&source, &dependency.package, &cancel)?; + return Ok(vec![DependencyVersions { dependency, versions, update: None }]); + } + if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") { return Ok(Vec::new()); } let mut out = Vec::new(); diff --git a/crates/ide-host/src/ops/dependencies/go_tests.rs b/crates/ide-host/src/ops/dependencies/go_tests.rs new file mode 100644 index 000000000..cdec57e0e --- /dev/null +++ b/crates/ide-host/src/ops/dependencies/go_tests.rs @@ -0,0 +1,107 @@ +use super::*; +use ide_workspace::dependencies::VersionCache; +use std::os::unix::fs::PermissionsExt; +use std::time::{Duration, Instant}; + +fn source(dir: &Path, script: &str) -> GoModules { + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let go = bin.join("go"); + std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap(); + GoModules { + root: dir.to_owned(), + env: BTreeMap::from([ + ("PATH".into(), bin.to_string_lossy().into_owned()), + ("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()), + ("GOPROXY".into(), "https://user:secret@proxy.example".into()), + ("GOPRIVATE".into(), "private.example/token".into()), + ("GOWORK".into(), "broken.work".into()), + ("GOFLAGS".into(), "-mod=vendor".into()), + ]), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() { + let dir = tempfile::tempdir().unwrap(); + let source = source( + dir.path(), + r#"set -eu +[ "$PWD" != "$PROJECT_ROOT" ] +[ "$GOPROXY" = 'https://user:secret@proxy.example' ] +[ "$GOPRIVATE" = 'private.example/token' ] +[ "$GOWORK" = off ] +[ "$GOFLAGS" = '' ] +[ "$GO111MODULE" = on ] +[ "$GIT_TERMINAL_PROMPT" = 0 ] +[ "$1 $2 $3 $4" = 'list -m -versions -f' ] +[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ] +[ "$6 $7" = '-- example.com/Org/Lib/v2' ] +printf 'v2.9.0\nv2.10.0\n'"#, + ); + let key = source.cache_key("example.com/Org/Lib/v2").unwrap(); + assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap()); + let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() }; + assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap()); + other.root = source.root.clone(); + other.env.insert("GOPROXY".into(), "https://other.example".into()); + assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap()); + let cache_dir = dir.path().join("cache"); + let disk = cache_dir.clone(); + let versions = tokio::task::spawn_blocking(move || { + VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new()) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(versions, ["v2.10.0", "v2.9.0"]); + let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect(); + assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter"); + assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json")); + let cached = std::fs::read_to_string(entries[0].path()).unwrap(); + for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] { + assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_go_queries_are_silent_and_not_persisted() { + let dir = tempfile::tempdir().unwrap(); + let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1"); + let cache_dir = dir.path().join("cache"); + let disk = cache_dir.clone(); + let versions = tokio::task::spawn_blocking(move || { + VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new()) + }) + .await + .unwrap() + .unwrap(); + assert!(versions.is_empty()); + assert!(!disk.exists()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() { + for cancel_parent in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let source = source(dir.path(), "exec /bin/sleep 60"); + let cancel = CancellationToken::new(); + let query = cancel.clone(); + let started = Instant::now(); + let work = tokio::task::spawn_blocking(move || { + VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query) + }); + if cancel_parent { + tokio::time::sleep(Duration::from_millis(150)).await; + cancel.cancel(); + assert!(work.await.unwrap().is_err()); + assert!(started.elapsed() < Duration::from_secs(3)); + } else { + assert!(work.await.unwrap().unwrap().is_empty()); + assert!(started.elapsed() >= Duration::from_secs(10)); + assert!(started.elapsed() < Duration::from_secs(15)); + assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller"); + } + } +} diff --git a/crates/ide-sdk/src/go.rs b/crates/ide-sdk/src/go.rs index 3a30f772c..d6a815d3e 100644 --- a/crates/ide-sdk/src/go.rs +++ b/crates/ide-sdk/src/go.rs @@ -228,7 +228,7 @@ mod command_tests { } } -pub fn debug_environment( +pub fn configure_environment( root: &std::path::Path, settings: &ide_core::settings::Settings, env: &mut std::collections::BTreeMap, diff --git a/crates/ide-workspace/src/dependencies/cache.rs b/crates/ide-workspace/src/dependencies/cache.rs index 7be33fe7e..fb448e230 100644 --- a/crates/ide-workspace/src/dependencies/cache.rs +++ b/crates/ide-workspace/src/dependencies/cache.rs @@ -39,11 +39,11 @@ impl VersionCache { package: &str, cancel: &CancellationToken, ) -> IdeResult> { - let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); - if !safe(source.id()) || !safe(package) || package.len() > 64 { + let Some(key) = source.cache_key(package).filter(|key| { + !key.is_empty() && key.len() <= 128 && key.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) + }) else { return Ok(Vec::new()); - } - let key = format!("{}-{}", source.id(), package.to_ascii_lowercase()); + }; let slot = self.entries.lock().entry(key.clone()).or_default().clone(); let mut cached = loop { if cancel.is_cancelled() { diff --git a/crates/ide-workspace/src/dependencies/go.rs b/crates/ide-workspace/src/dependencies/go.rs new file mode 100644 index 000000000..c93e797a6 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/go.rs @@ -0,0 +1,118 @@ +use super::ManifestDependency; + +fn module_path(path: &str) -> bool { + let Some(domain) = path.split('/').next() else { return false }; + path.len() <= 512 + && domain.contains('.') + && !domain.starts_with(['.', '-']) + && path.split('/').all(|part| { + !part.is_empty() + && !part.ends_with('.') + && part.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)) + }) +} + +/// Finds the version at a UTF-16 offset, including incomplete require lines. +pub fn dependency_at(text: &str, offset: u32) -> Option { + let mut block = None; + let mut start = 0; + for line in text.split_inclusive('\n') { + let line_len = line.encode_utf16().count() as u32; + let code = line.split("//").next()?.trim_end_matches(['\r', '\n']); + let trimmed = code.trim(); + if offset < start + line_len || offset == start + line_len && !line.ends_with('\n') { + let body = match block { + Some("require") => code.trim_start(), + None => code.trim_start().strip_prefix("require")?.strip_prefix([' ', '\t'])?.trim_start(), + _ => return None, + }; + let (path, rest) = body.split_once([' ', '\t'])?; + let package = path.trim_matches('"'); + if !module_path(package) { + return None; + } + let version = rest.trim_start(); + let token = version.split_ascii_whitespace().next().unwrap_or_default(); + let from = start + code[..code.len() - version.len()].encode_utf16().count() as u32; + let (requirement, from) = match token.strip_prefix('"') { + Some(quoted) => (quoted.strip_suffix('"').unwrap_or(quoted), from + 1), + None => (token, from), + }; + let to = from + requirement.encode_utf16().count() as u32; + return (from <= offset && offset <= to).then(|| ManifestDependency { + package: package.to_owned(), + requirement: requirement.to_owned(), + from, + to, + }); + } + if trimmed == ")" { + block = None; + } else if block.is_none() { + block = trimmed.strip_suffix('(').map(str::trim); + } + start += line_len; + } + None +} + +/// Go lists versions in ascending semantic order. Ignore tool output unrelated to versions. +pub fn listed_versions(output: &str) -> Vec { + output + .lines() + .rev() + .map(str::trim) + .filter(|line| line.strip_prefix('v').is_some_and(|version| semver::Version::parse(version).is_ok())) + .map(str::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completes_blank_partial_and_quoted_require_versions() { + for (text, package, version) in [ + ("module example.com/m\nrequire golang.org/x/text |", "golang.org/x/text", ""), + ( + "// δΈ­ζ–‡ πŸ˜€\r\nrequire (\r\n\tgithub.com/Org/Lib/v2 v2.|1.0 // indirect\r\n)\r\n", + "github.com/Org/Lib/v2", + "v2.1.0", + ), + ("require \"rsc.io/quote\" \"v1.|2.0\"", "rsc.io/quote", "v1.2.0"), + ] { + let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32; + let text = text.replace('|', ""); + let item = dependency_at(&text, offset).unwrap(); + assert_eq!(item.package, package); + assert_eq!(item.requirement, version); + let units: Vec<_> = text.encode_utf16().collect(); + assert_eq!(String::from_utf16(&units[item.from as usize..item.to as usize]).unwrap(), version); + } + } + + #[test] + fn ignores_comments_other_directives_and_non_module_arguments() { + for text in [ + "// require rsc.io/quote v|", + "require rsc.io/quote v1.2.0 // in|direct", + "require rsc.io/qu|ote v1.2.0", + "replace (\n\trsc.io/quote v|\n)", + "require ../quote v|", + "require -flag.example v|", + "require rsc.io/quote@latest v|", + ] { + let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32; + assert!(dependency_at(&text.replace('|', ""), offset).is_none(), "{text}"); + } + } + + #[test] + fn keeps_go_version_spelling_and_prefers_newer_versions() { + assert_eq!( + listed_versions("go: notice\nv1.9.0\nv1.10.0-rc.1\nv2.0.0+incompatible\n"), + ["v2.0.0+incompatible", "v1.10.0-rc.1", "v1.9.0"] + ); + } +} diff --git a/crates/ide-workspace/src/dependencies/mod.rs b/crates/ide-workspace/src/dependencies/mod.rs index eef3a69a3..9b802782a 100644 --- a/crates/ide-workspace/src/dependencies/mod.rs +++ b/crates/ide-workspace/src/dependencies/mod.rs @@ -1,6 +1,7 @@ //! Manifest dependencies and repository versions, independent of editor and transport. mod cache; pub mod cargo; +pub mod go; pub use cache::VersionCache; use ide_core::{CancellationToken, IdeResult}; @@ -11,6 +12,12 @@ use specta::Type; pub trait DependencyVersionSource: Send + Sync { /// A filesystem-safe, stable repository identifier. fn id(&self) -> &'static str; + /// Override for case-sensitive module paths or project-specific repositories. + fn cache_key(&self, package: &str) -> Option { + let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); + let id = self.id(); + (safe(id) && safe(package) && package.len() <= 64).then(|| format!("{id}-{}", package.to_ascii_lowercase())) + } /// Non-yanked versions in descending semantic order. fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult>; } diff --git a/src/features/dependencies/dependencies.test.ts b/src/features/dependencies/dependencies.test.ts index e3322c592..53cae7e8d 100644 --- a/src/features/dependencies/dependencies.test.ts +++ b/src/features/dependencies/dependencies.test.ts @@ -46,14 +46,14 @@ afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); -function editor(path = "/p/Cargo.toml", readOnly = false) { +function editor(path = "/p/Cargo.toml", readOnly = false, source = text) { const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly }; const contribution = editorContributions().find((c) => c.id === "dependency-versions")!; const view = new EditorView({ parent: document.body, state: EditorState.create({ - doc: text, - selection: { anchor: from }, + doc: source, + selection: { anchor: Math.min(from, source.length) }, extensions: [lspDoc.of(ctx), contribution.extensions(ctx)], }), }); @@ -208,3 +208,37 @@ describe("Cargo dependency versions", () => { expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" }); }); }); + +describe("Go module dependency versions", () => { + it("requests versions at the caret and replaces the entire version without running inspections", async () => { + const source = "module example.com/app\n\nrequire (\n\tgolang.org/x/text v0.1.0 // indirect\n)\n"; + const from = source.indexOf("v0.1.0"); + const to = from + "v0.1.0".length; + const view = editor("/p/go.mod", false, source); + await settle(); + expect(dependencyVersions).not.toHaveBeenCalled(); + dependencyVersions.mockResolvedValue({ + status: "ok", + data: [ + { + dependency: { package: "golang.org/x/text", requirement: "v0.1.0", from, to }, + versions: ["v1.2.0", "v0.12.0", "v0.1.0"], + update: null, + }, + ], + }); + const complete = view.state.languageDataAt<(ctx: CompletionContext) => Promise>( + "openideaCompletion", + from, + )[0]!; + expect(await complete(new CompletionContext(view.state, from + 3, false, view))).toMatchObject({ + from, + to, + options: [ + { label: "v0.12.0", apply: "v0.12.0" }, + { label: "v0.1.0", apply: "v0.1.0" }, + ], + }); + expect(dependencyVersions).toHaveBeenCalledExactlyOnceWith(7, from + 3, expect.any(Number)); + }); +}); diff --git a/src/features/dependencies/index.ts b/src/features/dependencies/index.ts index 3a47f969f..c90df0743 100644 --- a/src/features/dependencies/index.ts +++ b/src/features/dependencies/index.ts @@ -168,12 +168,25 @@ function extension(doc: EditorDocContext): Extension { }, }; }); + return [ + versionsField, + plugin, + intentionProviders.of((view) => { + const selection = view.state.selection.main; + return dependencyFixes(view, selection.from, selection.to); + }), + quickFixProviders.of(dependencyFixes), + versionCompletion(doc), + ]; +} + +function versionCompletion(doc: EditorDocContext): Extension { const completion = async (ctx: CompletionContext): Promise => { if (!isTauri()) return null; const id = nextRequestId(); const text = ctx.state.doc; const root = useProject.getState().project?.root; - ctx.addEventListener("abort", () => void commands.cancelRequest(id)); + ctx.addEventListener("abort", () => void commands.cancelRequest(id), { onDocChange: true }); try { // The completion context is immutable; only use it while its document is current. const view = ctx.view; @@ -188,21 +201,17 @@ function extension(doc: EditorDocContext): Extension { return null; } }; - return [ - versionsField, - plugin, - intentionProviders.of((view) => { - const selection = view.state.selection.main; - return dependencyFixes(view, selection.from, selection.to); - }), - quickFixProviders.of(dependencyFixes), - EditorState.languageData.of(() => [{ openideaCompletion: completion }]), - ]; + return EditorState.languageData.of(() => [{ openideaCompletion: completion }]); } export function activate(): () => void { return registerEditorContribution({ id: "dependency-versions", - extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []), + extensions: (doc) => { + if (doc.virtual || doc.readOnly) return []; + if (/(?:^|\/)Cargo\.toml$/.test(doc.path)) return extension(doc); + if (/(?:^|\/)go\.mod$/.test(doc.path)) return versionCompletion(doc); + return []; + }, }); }