diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbc09fb2..18bee573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: - name: Generate per-crate coverage run: | # Generate coverage for each crate separately - for crate in deps-core deps-cargo deps-npm deps-pypi deps-lsp; do + for crate in deps-core deps-cargo deps-npm deps-pypi deps-go deps-lsp; do echo "Generating coverage for $crate..." cargo llvm-cov --all-features --package "$crate" --lcov \ --output-path "lcov-$crate.info" nextest 2>/dev/null || true @@ -261,6 +261,14 @@ jobs: flags: deps-pypi fail_ci_if_error: false + - name: Upload deps-go coverage + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: lcov-deps-go.info + flags: deps-go + fail_ci_if_error: false + - name: Upload deps-lsp coverage uses: codecov/codecov-action@v5 with: diff --git a/.gitignore b/.gitignore index d737ea21..7c6202cd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ target # Testing .snapshots/ +**/snapshots/ # Local development .local/ diff --git a/Cargo.lock b/Cargo.lock index 941375a9..97f5582d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "deps-go" +version = "0.4.1" +dependencies = [ + "async-trait", + "deps-core", + "once_cell", + "regex", + "semver", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tokio-test", + "tower-lsp-server", + "tracing", +] + [[package]] name = "deps-lsp" version = "0.4.1" @@ -408,6 +427,7 @@ dependencies = [ "dashmap", "deps-cargo", "deps-core", + "deps-go", "deps-npm", "deps-pypi", "futures", diff --git a/Cargo.toml b/Cargo.toml index 85b9e6bd..f6df9470 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ deps-core = { version = "0.4.1", path = "crates/deps-core" } deps-cargo = { version = "0.4.1", path = "crates/deps-cargo" } deps-npm = { version = "0.4.1", path = "crates/deps-npm" } deps-pypi = { version = "0.4.1", path = "crates/deps-pypi" } +deps-go = { version = "0.4.1", path = "crates/deps-go" } deps-lsp = { version = "0.4.1", path = "crates/deps-lsp" } futures = "0.3" insta = "1" @@ -28,6 +29,7 @@ once_cell = "1.20" pep440_rs = "0.7" pep508_rs = "0.9" bytes = "1" +regex = "1" reqwest = { version = "0.12", default-features = false } semver = "1" serde = "1" diff --git a/README.md b/README.md index 27d5857a..f9f7725c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A universal Language Server Protocol (LSP) server for dependency management acro - **Intelligent Autocomplete** — Package names, versions, and feature flags - **Version Hints** — Inlay hints showing latest available versions -- **Lock File Support** — Reads resolved versions from Cargo.lock, package-lock.json, poetry.lock, uv.lock +- **Lock File Support** — Reads resolved versions from Cargo.lock, package-lock.json, poetry.lock, uv.lock, go.sum - **Diagnostics** — Warnings for outdated, unknown, or yanked dependencies - **Hover Information** — Package descriptions with resolved version from lock file - **Code Actions** — Quick fixes to update dependencies @@ -43,10 +43,14 @@ deps-lsp is optimized for responsiveness: | Rust/Cargo | `Cargo.toml` | ✅ Supported | | npm | `package.json` | ✅ Supported | | Python/PyPI | `pyproject.toml` | ✅ Supported | +| Go Modules | `go.mod` | ✅ Supported | > [!NOTE] > PyPI support includes PEP 621, PEP 735 (dependency-groups), and Poetry formats. +> [!NOTE] +> Go support includes require, replace, and exclude directives with pseudo-version handling. + ## Installation ### From crates.io @@ -185,6 +189,7 @@ deps-lsp/ │ ├── deps-cargo/ # Cargo.toml parser + crates.io registry │ ├── deps-npm/ # package.json parser + npm registry │ ├── deps-pypi/ # pyproject.toml parser + PyPI registry +│ ├── deps-go/ # go.mod parser + proxy.golang.org │ ├── deps-lsp/ # Main LSP server │ └── deps-zed/ # Zed extension (WASM) ├── .config/ # nextest configuration diff --git a/crates/deps-core/README.md b/crates/deps-core/README.md index fb3ab89b..337cb341 100644 --- a/crates/deps-core/README.md +++ b/crates/deps-core/README.md @@ -7,7 +7,7 @@ Core abstractions for deps-lsp: traits, caching, and generic LSP handlers. -This crate provides the shared infrastructure used by ecosystem-specific crates (`deps-cargo`, `deps-npm`, `deps-pypi`). +This crate provides the shared infrastructure used by ecosystem-specific crates (`deps-cargo`, `deps-npm`, `deps-pypi`, `deps-go`). ## Features diff --git a/crates/deps-go/Cargo.toml b/crates/deps-go/Cargo.toml new file mode 100644 index 00000000..9c3f0fb0 --- /dev/null +++ b/crates/deps-go/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "deps-go" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Go module support for deps-lsp" + +[dependencies] +deps-core = { workspace = true } +async-trait.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tower-lsp-server.workspace = true +tracing.workspace = true +regex.workspace = true +once_cell.workspace = true +semver.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } +tokio-test.workspace = true +tempfile.workspace = true diff --git a/crates/deps-go/README.md b/crates/deps-go/README.md new file mode 100644 index 00000000..fb1c94d1 --- /dev/null +++ b/crates/deps-go/README.md @@ -0,0 +1,74 @@ +# deps-go + +[![Crates.io](https://img.shields.io/crates/v/deps-go)](https://crates.io/crates/deps-go) +[![docs.rs](https://img.shields.io/docsrs/deps-go)](https://docs.rs/deps-go) +[![codecov](https://codecov.io/gh/bug-ops/deps-lsp/graph/badge.svg?token=S71PTINTGQ&flag=deps-go)](https://codecov.io/gh/bug-ops/deps-lsp) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](../../LICENSE) + +Go modules support for deps-lsp. + +This crate provides parsing and registry integration for Go's module ecosystem. + +## Features + +- **go.mod Parsing** — Parse `go.mod` with position tracking for all directives +- **Directive Support** — Handle `require`, `replace`, `exclude`, and `retract` directives +- **Indirect Dependencies** — Detect and mark indirect dependencies (`// indirect`) +- **Pseudo-versions** — Parse and validate Go pseudo-version format +- **proxy.golang.org** — Fetch module versions from Go module proxy +- **Module Path Escaping** — Proper URL encoding for uppercase characters +- **EcosystemHandler** — Implements `deps_core::EcosystemHandler` trait + +## Usage + +```toml +[dependencies] +deps-go = "0.4" +``` + +```rust +use deps_go::{parse_go_mod, GoRegistry}; + +let dependencies = parse_go_mod(content, &uri)?; +let registry = GoRegistry::new(cache); +let versions = registry.get_versions("github.com/gin-gonic/gin").await?; +``` + +## Supported Directives + +### require + +```go +require github.com/gin-gonic/gin v1.9.1 +require ( + github.com/stretchr/testify v1.8.4 + golang.org/x/sync v0.5.0 // indirect +) +``` + +### replace + +```go +replace github.com/old/module => github.com/new/module v1.0.0 +replace github.com/local/module => ../local/module +``` + +### exclude + +```go +exclude github.com/pkg/module v1.2.3 +``` + +## Pseudo-version Support + +Handles Go's pseudo-version format for unreleased commits: + +``` +v0.0.0-20191109021931-daa7c04131f5 +``` + +Extracts base version and timestamp for display. + +## License + +[MIT](../../LICENSE) diff --git a/crates/deps-go/src/ecosystem.rs b/crates/deps-go/src/ecosystem.rs new file mode 100644 index 00000000..979d4986 --- /dev/null +++ b/crates/deps-go/src/ecosystem.rs @@ -0,0 +1,783 @@ +//! Go modules ecosystem implementation for deps-lsp. +//! +//! This module implements the `Ecosystem` trait for Go projects, +//! providing LSP functionality for `go.mod` files. + +use async_trait::async_trait; +use std::any::Any; +use std::collections::HashMap; +use std::sync::Arc; +use tower_lsp_server::ls_types::{ + CodeAction, CompletionItem, Diagnostic, Hover, InlayHint, Position, Uri, +}; + +use deps_core::{ + Ecosystem, EcosystemConfig, ParseResult as ParseResultTrait, Registry, Result, lsp_helpers, +}; + +use crate::formatter::GoFormatter; +use crate::registry::GoRegistry; + +/// Go modules ecosystem implementation. +/// +/// Provides LSP functionality for go.mod files, including: +/// - Dependency parsing with position tracking +/// - Version information from proxy.golang.org +/// - Inlay hints for latest versions +/// - Hover tooltips with package metadata +/// - Code actions for version updates +/// - Diagnostics for unknown packages +pub struct GoEcosystem { + registry: Arc, + formatter: GoFormatter, +} + +impl GoEcosystem { + /// Creates a new Go ecosystem with the given HTTP cache. + pub fn new(cache: Arc) -> Self { + Self { + registry: Arc::new(GoRegistry::new(cache)), + formatter: GoFormatter, + } + } + + /// Completes package names. + /// + /// Go doesn't have a centralized search API like crates.io or npm. + /// Users typically know the full module path (e.g., github.com/gin-gonic/gin). + /// This implementation returns empty results for now. + /// + /// Future enhancements could include: + /// - Popular packages database + /// - Local workspace module paths + /// - Integration with go.sum for recently used modules + async fn complete_package_names(&self, _prefix: &str) -> Vec { + // Go modules don't have a centralized search API + // Users typically know the full module path + vec![] + } + + /// Completes version strings for a specific package. + /// + /// Fetches versions from proxy.golang.org and filters by prefix. + /// Returns up to 20 results, newest versions first. + async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec { + use deps_core::completion::build_version_completion; + + // Fetch all versions for the package + let versions = match self.registry.get_versions(package_name).await { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to fetch versions for '{}': {}", package_name, e); + return vec![]; + } + }; + + let insert_range = tower_lsp_server::ls_types::Range::default(); + + // Single-pass: collect filtered results first + let filtered: Vec<_> = versions + .iter() + .filter(|v| v.version.starts_with(prefix) && !v.retracted) + .take(20) + .collect(); + + if filtered.is_empty() { + // No prefix match, show up to 20 non-retracted versions (newest first) + versions + .iter() + .filter(|v| !v.retracted) + .take(20) + .map(|v| { + build_version_completion( + v as &dyn deps_core::Version, + package_name, + insert_range, + ) + }) + .collect() + } else { + // Use filtered results + filtered + .iter() + .map(|v| { + build_version_completion( + *v as &dyn deps_core::Version, + package_name, + insert_range, + ) + }) + .collect() + } + } + + /// Completes feature flags for a specific package. + /// + /// Go modules don't have a feature flag system like Cargo. + /// Returns empty results. + async fn complete_features(&self, _package_name: &str, _prefix: &str) -> Vec { + // Go modules don't have feature flags + vec![] + } +} + +#[async_trait] +impl Ecosystem for GoEcosystem { + fn id(&self) -> &'static str { + "go" + } + + fn display_name(&self) -> &'static str { + "Go Modules" + } + + fn manifest_filenames(&self) -> &[&'static str] { + &["go.mod"] + } + + fn lockfile_filenames(&self) -> &[&'static str] { + &["go.sum"] + } + + async fn parse_manifest(&self, content: &str, uri: &Uri) -> Result> { + let result = crate::parser::parse_go_mod(content, uri)?; + Ok(Box::new(result)) + } + + fn registry(&self) -> Arc { + self.registry.clone() as Arc + } + + fn lockfile_provider(&self) -> Option> { + Some(Arc::new(crate::lockfile::GoSumParser)) + } + + async fn generate_inlay_hints( + &self, + parse_result: &dyn ParseResultTrait, + cached_versions: &HashMap, + resolved_versions: &HashMap, + config: &EcosystemConfig, + ) -> Vec { + lsp_helpers::generate_inlay_hints( + parse_result, + cached_versions, + resolved_versions, + config, + &self.formatter, + ) + } + + async fn generate_hover( + &self, + parse_result: &dyn ParseResultTrait, + position: Position, + cached_versions: &HashMap, + resolved_versions: &HashMap, + ) -> Option { + lsp_helpers::generate_hover( + parse_result, + position, + cached_versions, + resolved_versions, + self.registry.as_ref(), + &self.formatter, + ) + .await + } + + async fn generate_code_actions( + &self, + parse_result: &dyn ParseResultTrait, + position: Position, + _cached_versions: &HashMap, + uri: &Uri, + ) -> Vec { + lsp_helpers::generate_code_actions( + parse_result, + position, + uri, + self.registry.as_ref(), + &self.formatter, + ) + .await + } + + async fn generate_diagnostics( + &self, + parse_result: &dyn ParseResultTrait, + _cached_versions: &HashMap, + _uri: &Uri, + ) -> Vec { + lsp_helpers::generate_diagnostics(parse_result, self.registry.as_ref(), &self.formatter) + .await + } + + async fn generate_completions( + &self, + parse_result: &dyn ParseResultTrait, + position: Position, + content: &str, + ) -> Vec { + use deps_core::completion::{CompletionContext, detect_completion_context}; + + let context = detect_completion_context(parse_result, position, content); + + match context { + CompletionContext::PackageName { prefix } => self.complete_package_names(&prefix).await, + CompletionContext::Version { + package_name, + prefix, + } => self.complete_versions(&package_name, &prefix).await, + CompletionContext::Feature { + package_name, + prefix, + } => self.complete_features(&package_name, &prefix).await, + CompletionContext::None => vec![], + } + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{GoDependency, GoDirective}; + use deps_core::Dependency; + use std::collections::HashMap; + use tower_lsp_server::ls_types::{InlayHintLabel, Position, Range}; + + /// Mock dependency for testing + fn mock_dependency(name: &str, version: Option<&str>, line: u32) -> GoDependency { + GoDependency { + module_path: name.to_string(), + module_path_range: Range::new( + Position::new(line, 0), + Position::new(line, name.len() as u32), + ), + version: version.map(String::from), + version_range: version + .map(|_| Range::new(Position::new(line, 0), Position::new(line, 10))), + directive: GoDirective::Require, + indirect: false, + } + } + + /// Mock parse result for testing + struct MockParseResult { + dependencies: Vec, + uri: Uri, + } + + impl deps_core::ParseResult for MockParseResult { + fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> { + self.dependencies + .iter() + .map(|d| d as &dyn deps_core::Dependency) + .collect() + } + + fn workspace_root(&self) -> Option<&std::path::Path> { + None + } + + fn uri(&self) -> &Uri { + &self.uri + } + + fn as_any(&self) -> &dyn Any { + self + } + } + + #[test] + fn test_ecosystem_id() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + assert_eq!(ecosystem.id(), "go"); + } + + #[test] + fn test_ecosystem_display_name() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + assert_eq!(ecosystem.display_name(), "Go Modules"); + } + + #[test] + fn test_ecosystem_manifest_filenames() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + assert_eq!(ecosystem.manifest_filenames(), &["go.mod"]); + } + + #[test] + fn test_ecosystem_lockfile_filenames() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + assert_eq!(ecosystem.lockfile_filenames(), &["go.sum"]); + } + + #[test] + fn test_generate_inlay_hints_up_to_date() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.1"), + 5, + )], + uri, + }; + + let mut cached_versions = HashMap::new(); + cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string()); + + let config = EcosystemConfig { + show_up_to_date_hints: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let resolved_versions = HashMap::new(); + let hints = tokio_test::block_on(ecosystem.generate_inlay_hints( + &parse_result, + &cached_versions, + &resolved_versions, + &config, + )); + + assert_eq!(hints.len(), 1); + match &hints[0].label { + InlayHintLabel::String(s) => assert_eq!(s, "✅"), + _ => panic!("Expected String label"), + } + } + + #[test] + fn test_generate_inlay_hints_needs_update() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.0"), + 5, + )], + uri, + }; + + let mut cached_versions = HashMap::new(); + cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string()); + + let config = EcosystemConfig { + show_up_to_date_hints: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let resolved_versions = HashMap::new(); + let hints = tokio_test::block_on(ecosystem.generate_inlay_hints( + &parse_result, + &cached_versions, + &resolved_versions, + &config, + )); + + assert_eq!(hints.len(), 1); + match &hints[0].label { + InlayHintLabel::String(s) => assert_eq!(s, "❌ v1.9.1"), + _ => panic!("Expected String label"), + } + } + + #[test] + fn test_generate_inlay_hints_hide_up_to_date() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.1"), + 5, + )], + uri, + }; + + let mut cached_versions = HashMap::new(); + cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string()); + + let config = EcosystemConfig { + show_up_to_date_hints: false, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let resolved_versions = HashMap::new(); + let hints = tokio_test::block_on(ecosystem.generate_inlay_hints( + &parse_result, + &cached_versions, + &resolved_versions, + &config, + )); + + assert_eq!(hints.len(), 0); + } + + #[test] + fn test_generate_inlay_hints_no_version_range() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let mut dep = mock_dependency("github.com/gin-gonic/gin", Some("v1.9.1"), 5); + dep.version_range = None; + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![dep], + uri, + }; + + let mut cached_versions = HashMap::new(); + cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string()); + + let config = EcosystemConfig { + show_up_to_date_hints: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let resolved_versions = HashMap::new(); + let hints = tokio_test::block_on(ecosystem.generate_inlay_hints( + &parse_result, + &cached_versions, + &resolved_versions, + &config, + )); + + assert_eq!(hints.len(), 0); + } + + #[test] + fn test_as_any() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + // Verify we can downcast + let any = ecosystem.as_any(); + assert!(any.is::()); + } + + #[tokio::test] + async fn test_complete_package_names_empty() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + // Go doesn't have package search, should always return empty + let results = ecosystem.complete_package_names("github").await; + assert!(results.is_empty()); + } + + #[tokio::test] + #[ignore] // Requires network access + async fn test_complete_versions_real() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let results = ecosystem + .complete_versions("github.com/gin-gonic/gin", "v1.9") + .await; + assert!(!results.is_empty()); + assert!(results.iter().all(|r| r.label.starts_with("v1.9"))); + } + + #[tokio::test] + async fn test_complete_versions_unknown_package() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + // Unknown package should return empty (graceful degradation) + let results = ecosystem + .complete_versions("github.com/nonexistent/package12345", "v1.0") + .await; + assert!(results.is_empty()); + } + + #[tokio::test] + async fn test_complete_features_always_empty() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + // Go doesn't have features, should always return empty + let results = ecosystem + .complete_features("github.com/gin-gonic/gin", "") + .await; + assert!(results.is_empty()); + } + + #[tokio::test] + #[ignore] // Requires network access + async fn test_complete_versions_limit_20() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + // Test that we respect the 20 result limit + let results = ecosystem + .complete_versions("github.com/gin-gonic/gin", "v") + .await; + assert!(results.len() <= 20); + } + + #[tokio::test] + async fn test_generate_hover_on_module_path() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.1"), + 5, + )], + uri, + }; + + let position = Position::new(5, 5); + let cached_versions = HashMap::new(); + let resolved_versions = HashMap::new(); + + let hover = ecosystem + .generate_hover( + &parse_result, + position, + &cached_versions, + &resolved_versions, + ) + .await; + + // Returns hover with package URL + assert!(hover.is_some()); + let hover_content = hover.unwrap(); + let markdown = format!("{:?}", hover_content.contents); + assert!(markdown.contains("pkg.go.dev")); + } + + #[tokio::test] + async fn test_generate_hover_outside_dependency() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.1"), + 5, + )], + uri, + }; + + let position = Position::new(0, 0); + let cached_versions = HashMap::new(); + let resolved_versions = HashMap::new(); + + let hover = ecosystem + .generate_hover( + &parse_result, + position, + &cached_versions, + &resolved_versions, + ) + .await; + + assert!(hover.is_none()); + } + + #[tokio::test] + async fn test_generate_code_actions_on_module() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.0"), + 5, + )], + uri: uri.clone(), + }; + + let position = Position::new(5, 5); + let cached_versions = HashMap::new(); + + let actions = ecosystem + .generate_code_actions(&parse_result, position, &cached_versions, &uri) + .await; + + // Returns actions (open documentation link) + assert!(!actions.is_empty()); + } + + #[tokio::test] + #[ignore = "Requires network access to proxy.golang.org"] + async fn test_generate_diagnostics_basic() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![mock_dependency( + "github.com/gin-gonic/gin", + Some("v1.9.1"), + 5, + )], + uri, + }; + + let cached_versions = HashMap::new(); + + // Use timeout to prevent hanging + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + ecosystem.generate_diagnostics(&parse_result, &cached_versions, parse_result.uri()), + ) + .await; + + // Should complete within timeout + assert!(result.is_ok(), "Diagnostic generation timed out"); + } + + #[tokio::test] + async fn test_generate_completions_package_name() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let content = r#"module example.com/myapp + +go 1.21 + +require github.com/ +"#; + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![], + uri, + }; + + let position = Position::new(4, 19); + + let completions = ecosystem + .generate_completions(&parse_result, position, content) + .await; + + // Go doesn't support package search, should be empty + assert!(completions.is_empty()); + } + + #[tokio::test] + async fn test_generate_completions_outside_context() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let content = r#"module example.com/myapp + +go 1.21 +"#; + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let parse_result = MockParseResult { + dependencies: vec![], + uri, + }; + + let position = Position::new(0, 0); + + let completions = ecosystem + .generate_completions(&parse_result, position, content) + .await; + + assert!(completions.is_empty()); + } + + #[tokio::test] + async fn test_parse_manifest_valid() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let content = r#"module example.com/myapp + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +"#; + + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + + let result = ecosystem.parse_manifest(content, &uri).await; + assert!(result.is_ok()); + + let parse_result = result.unwrap(); + assert_eq!(parse_result.dependencies().len(), 1); + assert_eq!( + parse_result.dependencies()[0].name(), + "github.com/gin-gonic/gin" + ); + } + + #[tokio::test] + async fn test_parse_manifest_empty() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let content = ""; + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + + let result = ecosystem.parse_manifest(content, &uri).await; + assert!(result.is_ok()); + + let parse_result = result.unwrap(); + assert_eq!(parse_result.dependencies().len(), 0); + } + + #[test] + fn test_registry_returns_trait_object() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + let registry = ecosystem.registry(); + assert_eq!( + registry.package_url("github.com/gin-gonic/gin"), + "https://pkg.go.dev/github.com/gin-gonic/gin" + ); + } + + #[test] + fn test_lockfile_provider_exists() { + let cache = Arc::new(deps_core::HttpCache::new()); + let ecosystem = GoEcosystem::new(cache); + + assert!(ecosystem.lockfile_provider().is_some()); + } + + #[test] + fn test_mock_dependency_indirect() { + let mut dep = mock_dependency("github.com/example/pkg", Some("v1.0.0"), 10); + dep.indirect = true; + + assert!(dep.indirect); + assert_eq!(dep.name(), "github.com/example/pkg"); + } +} diff --git a/crates/deps-go/src/error.rs b/crates/deps-go/src/error.rs new file mode 100644 index 00000000..accca1af --- /dev/null +++ b/crates/deps-go/src/error.rs @@ -0,0 +1,232 @@ +//! Errors specific to Go module dependency handling. + +use thiserror::Error; + +/// Errors that can occur during Go module operations. +#[derive(Error, Debug)] +pub enum GoError { + /// Failed to parse go.mod file + #[error("Failed to parse go.mod: {source}")] + ParseError { + #[source] + source: Box, + }, + + /// Invalid version specifier + #[error("Invalid version specifier '{specifier}': {message}")] + InvalidVersionSpecifier { specifier: String, message: String }, + + /// Module not found in registry + #[error("Module '{module}' not found")] + ModuleNotFound { module: String }, + + /// Registry request failed + #[error("Registry request failed for '{module}': {source}")] + RegistryError { + module: String, + #[source] + source: Box, + }, + + /// Cache error + #[error("Cache error: {0}")] + CacheError(String), + + /// Invalid module path + #[error("Invalid module path: {0}")] + InvalidModulePath(String), + + /// Invalid pseudo-version format + #[error("Invalid pseudo-version '{version}': {reason}")] + InvalidPseudoVersion { version: String, reason: String }, + + /// Failed to deserialize proxy.golang.org API response + #[error("Failed to parse proxy.golang.org API response for '{module}': {source}")] + ApiResponseError { + module: String, + #[source] + source: serde_json::Error, + }, + + /// I/O error + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// Generic error wrapper + #[error(transparent)] + Other(#[from] Box), +} + +/// Result type alias for Go operations. +pub type Result = std::result::Result; + +impl GoError { + /// Helper for creating registry errors + pub fn registry_error( + module: impl Into, + error: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::RegistryError { + module: module.into(), + source: Box::new(error), + } + } + + /// Helper for creating invalid version specifier errors + pub fn invalid_version_specifier( + specifier: impl Into, + message: impl Into, + ) -> Self { + Self::InvalidVersionSpecifier { + specifier: specifier.into(), + message: message.into(), + } + } + + /// Helper for creating module not found errors + pub fn module_not_found(module: impl Into) -> Self { + Self::ModuleNotFound { + module: module.into(), + } + } + + /// Helper for creating invalid pseudo-version errors + pub fn invalid_pseudo_version(version: impl Into, reason: impl Into) -> Self { + Self::InvalidPseudoVersion { + version: version.into(), + reason: reason.into(), + } + } +} + +impl From for deps_core::DepsError { + fn from(err: GoError) -> Self { + match err { + GoError::ParseError { source } => deps_core::DepsError::ParseError { + file_type: "go.mod".into(), + source, + }, + GoError::InvalidVersionSpecifier { message, .. } => { + deps_core::DepsError::InvalidVersionReq(message) + } + GoError::ModuleNotFound { module } => { + deps_core::DepsError::CacheError(format!("Module '{}' not found", module)) + } + GoError::RegistryError { module, source } => deps_core::DepsError::ParseError { + file_type: format!("registry for {}", module), + source, + }, + GoError::CacheError(msg) => deps_core::DepsError::CacheError(msg), + GoError::InvalidModulePath(msg) => deps_core::DepsError::InvalidVersionReq(msg), + GoError::InvalidPseudoVersion { version, reason } => { + deps_core::DepsError::InvalidVersionReq(format!("{}: {}", version, reason)) + } + GoError::ApiResponseError { module: _, source } => deps_core::DepsError::Json(source), + GoError::Io(e) => deps_core::DepsError::Io(e), + GoError::Other(e) => deps_core::DepsError::ParseError { + file_type: "go".into(), + source: e, + }, + } + } +} + +impl From for GoError { + fn from(err: deps_core::DepsError) -> Self { + match err { + deps_core::DepsError::ParseError { source, .. } => GoError::ParseError { source }, + deps_core::DepsError::CacheError(msg) => GoError::CacheError(msg), + deps_core::DepsError::InvalidVersionReq(msg) => GoError::InvalidVersionSpecifier { + specifier: String::new(), + message: msg, + }, + deps_core::DepsError::Io(e) => GoError::Io(e), + deps_core::DepsError::Json(e) => GoError::ApiResponseError { + module: String::new(), + source: e, + }, + other => GoError::CacheError(other.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_construction() { + let err = GoError::ModuleNotFound { + module: "test/module".to_string(), + }; + assert_eq!(err.to_string(), "Module 'test/module' not found"); + } + + #[test] + fn test_error_conversion() { + let go_err = GoError::InvalidModulePath("invalid".to_string()); + let deps_err: deps_core::DepsError = go_err.into(); + assert!(matches!( + deps_err, + deps_core::DepsError::InvalidVersionReq(_) + )); + } + + #[test] + fn test_parse_error_conversion() { + let go_err = GoError::ParseError { + source: Box::new(std::io::Error::other("parse failed")), + }; + let deps_err: deps_core::DepsError = go_err.into(); + assert!(matches!(deps_err, deps_core::DepsError::ParseError { .. })); + } + + #[test] + fn test_registry_error_conversion() { + let go_err = GoError::RegistryError { + module: "test/module".to_string(), + source: Box::new(std::io::Error::other("network failed")), + }; + let deps_err: deps_core::DepsError = go_err.into(); + assert!(matches!(deps_err, deps_core::DepsError::ParseError { .. })); + } + + #[test] + fn test_io_error_conversion() { + let go_err = GoError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let deps_err: deps_core::DepsError = go_err.into(); + assert!(matches!(deps_err, deps_core::DepsError::Io(_))); + } + + #[test] + fn test_cache_error_conversion() { + let go_err = GoError::CacheError("cache miss".to_string()); + let deps_err: deps_core::DepsError = go_err.into(); + assert!(matches!(deps_err, deps_core::DepsError::CacheError(_))); + } + + #[test] + fn test_bidirectional_conversion() { + let deps_err = deps_core::DepsError::CacheError("test error".to_string()); + let go_err: GoError = deps_err.into(); + assert!(matches!(go_err, GoError::CacheError(_))); + } + + #[test] + fn test_helper_methods() { + let err = GoError::registry_error("test/module", std::io::Error::other("fail")); + assert!(matches!(err, GoError::RegistryError { .. })); + + let err = GoError::invalid_version_specifier("v1.0", "invalid"); + assert!(matches!(err, GoError::InvalidVersionSpecifier { .. })); + + let err = GoError::module_not_found("test/module"); + assert!(matches!(err, GoError::ModuleNotFound { .. })); + + let err = GoError::invalid_pseudo_version("v0.0.0", "bad format"); + assert!(matches!(err, GoError::InvalidPseudoVersion { .. })); + } +} diff --git a/crates/deps-go/src/formatter.rs b/crates/deps-go/src/formatter.rs new file mode 100644 index 00000000..5caa7dac --- /dev/null +++ b/crates/deps-go/src/formatter.rs @@ -0,0 +1,172 @@ +use deps_core::lsp_helpers::EcosystemFormatter; + +/// Formatter for Go module version strings and package URLs. +/// +/// Handles Go-specific version formatting: +/// - Versions are unquoted in go.mod (v1.2.3) +/// - Pseudo-versions (v0.0.0-20191109021931-daa7c04131f5) +/// - +incompatible suffix for v2+ modules without /v2 path +pub struct GoFormatter; + +impl EcosystemFormatter for GoFormatter { + fn format_version_for_code_action(&self, version: &str) -> String { + // Go versions in go.mod are unquoted: v1.2.3 + // Return version as-is since it should already have "v" prefix from registry + version.to_string() + } + + fn package_url(&self, name: &str) -> String { + // Use pkg.go.dev for package documentation + // URL encode special characters (@ and space) + let encoded = name.replace('@', "%40").replace(' ', "%20"); + format!("https://pkg.go.dev/{}", encoded) + } + + fn version_satisfies_requirement(&self, version: &str, requirement: &str) -> bool { + // For Go modules, version matching is typically exact + // However, we need to handle: + // 1. Exact match: v1.2.3 == v1.2.3 + // 2. Prefix match for pseudo-versions: v0.0.0-20191109021931-daa7c04131f5 starts with v0.0.0 + // 3. Prefix match for +incompatible: v2.0.0+incompatible starts with v2.0.0 + + if version == requirement { + return true; + } + + // Handle pseudo-versions and +incompatible suffix + // Check if version starts with requirement followed by a dot, hyphen, plus, or end + // This prevents false positives like v1.2.30 matching v1.2.3 + if let Some(suffix) = version.strip_prefix(requirement) { + return suffix.is_empty() + || suffix.starts_with('.') + || suffix.starts_with('-') + || suffix.starts_with('+'); + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_version_for_code_action() { + let formatter = GoFormatter; + + // Standard semantic version + assert_eq!(formatter.format_version_for_code_action("v1.2.3"), "v1.2.3"); + + // Pseudo-version + assert_eq!( + formatter.format_version_for_code_action("v0.0.0-20191109021931-daa7c04131f5"), + "v0.0.0-20191109021931-daa7c04131f5" + ); + + // Version with +incompatible + assert_eq!( + formatter.format_version_for_code_action("v2.0.0+incompatible"), + "v2.0.0+incompatible" + ); + } + + #[test] + fn test_package_url() { + let formatter = GoFormatter; + + // Standard package + assert_eq!( + formatter.package_url("github.com/gin-gonic/gin"), + "https://pkg.go.dev/github.com/gin-gonic/gin" + ); + + // Package with version path + assert_eq!( + formatter.package_url("github.com/go-redis/redis/v8"), + "https://pkg.go.dev/github.com/go-redis/redis/v8" + ); + + // Standard library package + assert_eq!(formatter.package_url("fmt"), "https://pkg.go.dev/fmt"); + + // Package with @ character (should be URL encoded) + assert_eq!( + formatter.package_url("github.com/user@org/package"), + "https://pkg.go.dev/github.com/user%40org/package" + ); + + // Package with space (should be URL encoded) + assert_eq!( + formatter.package_url("github.com/user/pkg name"), + "https://pkg.go.dev/github.com/user/pkg%20name" + ); + } + + #[test] + fn test_version_satisfies_requirement_exact_match() { + let formatter = GoFormatter; + + // Exact version match + assert!(formatter.version_satisfies_requirement("v1.2.3", "v1.2.3")); + assert!(formatter.version_satisfies_requirement("v0.1.0", "v0.1.0")); + } + + #[test] + fn test_version_satisfies_requirement_pseudo_version() { + let formatter = GoFormatter; + + // Pseudo-version prefix match + assert!( + formatter.version_satisfies_requirement("v0.0.0-20191109021931-daa7c04131f5", "v0.0.0") + ); + + // Full pseudo-version match + assert!(formatter.version_satisfies_requirement( + "v0.0.0-20191109021931-daa7c04131f5", + "v0.0.0-20191109021931-daa7c04131f5" + )); + } + + #[test] + fn test_version_satisfies_requirement_incompatible() { + let formatter = GoFormatter; + + // +incompatible suffix handling + assert!(formatter.version_satisfies_requirement("v2.0.0+incompatible", "v2.0.0")); + + // Exact match with +incompatible + assert!( + formatter.version_satisfies_requirement("v2.0.0+incompatible", "v2.0.0+incompatible") + ); + } + + #[test] + fn test_version_does_not_satisfy_requirement() { + let formatter = GoFormatter; + + // Different versions + assert!(!formatter.version_satisfies_requirement("v1.2.3", "v1.2.4")); + assert!(!formatter.version_satisfies_requirement("v2.0.0", "v1.0.0")); + + // Partial match that doesn't start with requirement + assert!(!formatter.version_satisfies_requirement("v1.2.3", "v1.2.3.4")); + } + + #[test] + fn test_version_satisfies_requirement_prefix_scenarios() { + let formatter = GoFormatter; + + // Version is prefix of requirement (should NOT match) + assert!(!formatter.version_satisfies_requirement("v1.2", "v1.2.3")); + + // Requirement is prefix of version with dot boundary (should match) + assert!(formatter.version_satisfies_requirement("v1.2.3", "v1.2")); + + // False positive prevention: v1.2.30 should NOT match v1.2.3 + assert!(!formatter.version_satisfies_requirement("v1.2.30", "v1.2.3")); + + // But v1.2.3.1 SHOULD match v1.2.3 (if it has dot boundary) + assert!(formatter.version_satisfies_requirement("v1.2.3.1", "v1.2.3")); + } +} diff --git a/crates/deps-go/src/lib.rs b/crates/deps-go/src/lib.rs new file mode 100644 index 00000000..0acb9e8b --- /dev/null +++ b/crates/deps-go/src/lib.rs @@ -0,0 +1,50 @@ +//! Go module ecosystem support for deps-lsp. +//! +//! This crate provides parsing, registry access, and LSP features for Go modules (go.mod files). +//! +//! # Features +//! +//! - Parse go.mod files with accurate position tracking +//! - Fetch version data from proxy.golang.org +//! - Generate LSP features (inlay hints, hover, completions) +//! - Support for go.mod directives: require, replace, exclude +//! +//! # Example +//! +//! ```no_run +//! use deps_go::parse_go_mod; +//! use tower_lsp_server::ls_types::Uri; +//! +//! let content = r#" +//! module example.com/myapp +//! +//! go 1.21 +//! +//! require github.com/gin-gonic/gin v1.9.1 +//! "#; +//! +//! let uri = Uri::from_file_path("/test/go.mod").unwrap(); +//! let result = parse_go_mod(content, &uri).unwrap(); +//! assert_eq!(result.dependencies.len(), 1); +//! ``` + +pub mod ecosystem; +pub mod error; +pub mod formatter; +pub mod lockfile; +pub mod parser; +pub mod registry; +pub mod types; +pub mod version; + +// Re-export commonly used types +pub use ecosystem::GoEcosystem; +pub use error::{GoError, Result}; +pub use formatter::GoFormatter; +pub use lockfile::{GoSumParser, parse_go_sum}; +pub use parser::{GoParseResult, parse_go_mod}; +pub use registry::{GoRegistry, package_url}; +pub use types::{GoDependency, GoDirective, GoMetadata, GoVersion}; +pub use version::{ + base_version_from_pseudo, compare_versions, escape_module_path, is_pseudo_version, +}; diff --git a/crates/deps-go/src/lockfile.rs b/crates/deps-go/src/lockfile.rs new file mode 100644 index 00000000..39771aa4 --- /dev/null +++ b/crates/deps-go/src/lockfile.rs @@ -0,0 +1,492 @@ +//! go.sum lock file parsing. +//! +//! Parses go.sum files to extract resolved dependency versions. +//! go.sum contains checksums for all modules used in a build, including +//! transitive dependencies and multiple versions. +//! +//! # go.sum Format +//! +//! Each line in go.sum has the format: +//! ```text +//! module_path version hash +//! ``` +//! +//! Example: +//! ```text +//! github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +//! github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs= +//! golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrq... +//! golang.org/x/sync v0.5.0/go.mod h1:RxMgew5V... +//! ``` +//! +//! # Line Types +//! +//! - Lines ending with `/go.mod` are module file checksums (skipped for version resolution) +//! - Lines with `h1:hash` are actual module content checksums (used for version resolution) +//! - A module may appear multiple times with different versions + +use async_trait::async_trait; +use deps_core::error::{DepsError, Result}; +use deps_core::lockfile::{ + LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource, + locate_lockfile_for_manifest, +}; +use std::path::{Path, PathBuf}; +use tower_lsp_server::ls_types::Uri; + +/// go.sum file parser. +/// +/// Implements lock file parsing for Go modules. +/// Supports both project-level and workspace-level go.sum files. +/// +/// # Lock File Location +/// +/// The parser searches for go.sum in the following order: +/// 1. Same directory as go.mod +/// 2. Parent directories (up to 5 levels) for workspace root +/// +/// # Examples +/// +/// ```no_run +/// use deps_go::lockfile::GoSumParser; +/// use deps_core::lockfile::LockFileProvider; +/// use tower_lsp_server::ls_types::Uri; +/// +/// # async fn example() -> deps_core::error::Result<()> { +/// let parser = GoSumParser; +/// let manifest_uri = Uri::from_file_path("/path/to/go.mod").unwrap(); +/// +/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) { +/// let resolved = parser.parse_lockfile(&lockfile_path).await?; +/// println!("Found {} resolved packages", resolved.len()); +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct GoSumParser; + +impl GoSumParser { + /// Lock file names for Go ecosystem. + const LOCKFILE_NAMES: &'static [&'static str] = &["go.sum"]; +} + +#[async_trait] +impl LockFileProvider for GoSumParser { + fn locate_lockfile(&self, manifest_uri: &Uri) -> Option { + locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES) + } + + async fn parse_lockfile(&self, lockfile_path: &Path) -> Result { + tracing::debug!("Parsing go.sum: {}", lockfile_path.display()); + + let content = tokio::fs::read_to_string(lockfile_path) + .await + .map_err(|e| DepsError::ParseError { + file_type: format!("go.sum at {}", lockfile_path.display()), + source: Box::new(e), + })?; + + let packages = parse_go_sum(&content); + + tracing::info!( + "Parsed go.sum: {} packages from {}", + packages.len(), + lockfile_path.display() + ); + + Ok(packages) + } +} + +/// Parses go.sum content and returns resolved packages. +/// +/// Filters out `/go.mod` entries (module file checksums) and only processes +/// module content checksums (lines with `h1:` hashes). +/// +/// When a module appears multiple times with different versions, the first +/// occurrence is used. This typically represents the version selected by +/// Go's minimal version selection algorithm. +/// +/// # Arguments +/// +/// * `content` - The go.sum file content +/// +/// # Returns +/// +/// A collection of resolved packages with their versions +/// +/// # Examples +/// +/// ``` +/// use deps_go::lockfile::parse_go_sum; +/// +/// let content = r#" +/// github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +/// github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs= +/// "#; +/// +/// let packages = parse_go_sum(content); +/// assert_eq!(packages.get_version("github.com/gin-gonic/gin"), Some("v1.9.1")); +/// ``` +pub fn parse_go_sum(content: &str) -> ResolvedPackages { + let mut packages = ResolvedPackages::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Skip /go.mod entries (we only want the h1: hash entries) + if line.contains("/go.mod ") { + continue; + } + + // Parse: module_path version h1:hash + // Valid go.sum lines must have at least 3 parts (module, version, hash) + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + let module_path = parts[0]; + let version = parts[1]; + let checksum = parts[2]; + + // Validate that the hash starts with 'h1:' (standard Go checksum format) + // This filters out malformed lines + if !checksum.starts_with("h1:") { + continue; + } + + // Only insert if not already present (first occurrence wins) + // This handles cases where multiple versions exist + if packages.get(module_path).is_none() { + packages.insert(ResolvedPackage { + name: module_path.to_string(), + version: version.to_string(), + source: ResolvedSource::Registry { + url: "https://proxy.golang.org".to_string(), + checksum: checksum.to_string(), + }, + dependencies: vec![], + }); + } + } + } + + packages +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_go_sum() { + let content = r#" +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs= +"#; + let packages = parse_go_sum(content); + assert_eq!( + packages.get_version("github.com/gin-gonic/gin"), + Some("v1.9.1") + ); + } + + #[test] + fn test_parse_multiple_modules() { + let content = r#" +github.com/gin-gonic/gin v1.9.1 h1:hash1= +golang.org/x/sync v0.5.0 h1:hash2= +github.com/stretchr/testify v1.8.4 h1:hash3= +"#; + let packages = parse_go_sum(content); + assert_eq!(packages.len(), 3); + assert_eq!( + packages.get_version("github.com/gin-gonic/gin"), + Some("v1.9.1") + ); + assert_eq!(packages.get_version("golang.org/x/sync"), Some("v0.5.0")); + assert_eq!( + packages.get_version("github.com/stretchr/testify"), + Some("v1.8.4") + ); + } + + #[test] + fn test_skip_go_mod_entries() { + let content = r#" +github.com/gin-gonic/gin v1.9.1/go.mod h1:mod_hash= +github.com/gin-gonic/gin v1.9.1 h1:actual_hash= +"#; + let packages = parse_go_sum(content); + assert_eq!(packages.len(), 1); + assert_eq!( + packages.get_version("github.com/gin-gonic/gin"), + Some("v1.9.1") + ); + } + + #[test] + fn test_first_version_wins() { + let content = r#" +github.com/pkg/errors v0.9.1 h1:hash1= +github.com/pkg/errors v0.8.0 h1:hash2= +"#; + let packages = parse_go_sum(content); + assert_eq!(packages.len(), 1); + // First occurrence should win + assert_eq!( + packages.get_version("github.com/pkg/errors"), + Some("v0.9.1") + ); + } + + #[test] + fn test_empty_content() { + let packages = parse_go_sum(""); + assert!(packages.is_empty()); + } + + #[test] + fn test_whitespace_handling() { + let content = " github.com/gin-gonic/gin v1.9.1 h1:hash= \n"; + let packages = parse_go_sum(content); + assert_eq!( + packages.get_version("github.com/gin-gonic/gin"), + Some("v1.9.1") + ); + } + + #[test] + fn test_lockfile_provider_trait() { + let parser = GoSumParser; + let manifest_path = "/test/go.mod"; + let uri = Uri::from_file_path(manifest_path).unwrap(); + + // Just verify the trait methods are callable + let _ = parser.locate_lockfile(&uri); + } + + #[test] + fn test_pseudo_version() { + let content = "golang.org/x/tools v0.0.0-20191109021931-daa7c04131f5 h1:hash=\n"; + let packages = parse_go_sum(content); + assert_eq!( + packages.get_version("golang.org/x/tools"), + Some("v0.0.0-20191109021931-daa7c04131f5") + ); + } + + #[test] + fn test_incompatible_version() { + let content = "github.com/some/module v2.0.0+incompatible h1:hash=\n"; + let packages = parse_go_sum(content); + assert_eq!( + packages.get_version("github.com/some/module"), + Some("v2.0.0+incompatible") + ); + } + + #[test] + fn test_malformed_line_ignored() { + let content = r#" +github.com/gin-gonic/gin v1.9.1 h1:hash= +invalid line with only one part +github.com/valid/pkg v1.0.0 h1:valid_hash= +"#; + let packages = parse_go_sum(content); + // Should only parse the valid lines + assert_eq!(packages.len(), 2); + assert_eq!( + packages.get_version("github.com/gin-gonic/gin"), + Some("v1.9.1") + ); + assert_eq!(packages.get_version("github.com/valid/pkg"), Some("v1.0.0")); + } + + #[tokio::test] + async fn test_parse_lockfile_simple() { + let lockfile_content = r#" +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL9t9/HBtKc7e/Q7Nb2nqKqTW8mHZy6E7k8m4dLvs= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrq= +golang.org/x/sync v0.5.0/go.mod h1:RxMgew5V= +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("go.sum"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = GoSumParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 2); + assert_eq!( + resolved.get_version("github.com/gin-gonic/gin"), + Some("v1.9.1") + ); + assert_eq!(resolved.get_version("golang.org/x/sync"), Some("v0.5.0")); + } + + #[tokio::test] + async fn test_parse_lockfile_empty() { + let lockfile_content = ""; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("go.sum"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = GoSumParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 0); + assert!(resolved.is_empty()); + } + + #[tokio::test] + async fn test_parse_lockfile_not_found() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("nonexistent.sum"); + + let parser = GoSumParser; + let result = parser.parse_lockfile(&lockfile_path).await; + + assert!(result.is_err()); + } + + #[test] + fn test_locate_lockfile_same_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("go.mod"); + let lock_path = temp_dir.path().join("go.sum"); + + std::fs::write(&manifest_path, "module test").unwrap(); + std::fs::write(&lock_path, "").unwrap(); + + let manifest_uri = Uri::from_file_path(&manifest_path).unwrap(); + let parser = GoSumParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), lock_path); + } + + #[test] + fn test_locate_lockfile_workspace_root() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_lock = temp_dir.path().join("go.sum"); + let member_dir = temp_dir.path().join("packages").join("member"); + std::fs::create_dir_all(&member_dir).unwrap(); + let member_manifest = member_dir.join("go.mod"); + + std::fs::write(&workspace_lock, "").unwrap(); + std::fs::write(&member_manifest, "module member").unwrap(); + + let manifest_uri = Uri::from_file_path(&member_manifest).unwrap(); + let parser = GoSumParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), workspace_lock); + } + + #[test] + fn test_locate_lockfile_not_found() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("go.mod"); + std::fs::write(&manifest_path, "module test").unwrap(); + + let manifest_uri = Uri::from_file_path(&manifest_path).unwrap(); + let parser = GoSumParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_none()); + } + + #[test] + fn test_is_lockfile_stale_not_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("go.sum"); + std::fs::write(&lockfile_path, "").unwrap(); + + let mtime = std::fs::metadata(&lockfile_path) + .unwrap() + .modified() + .unwrap(); + let parser = GoSumParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, mtime), + "Lock file should not be stale when mtime matches" + ); + } + + #[test] + fn test_is_lockfile_stale_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("go.sum"); + std::fs::write(&lockfile_path, "").unwrap(); + + let old_time = std::time::UNIX_EPOCH; + let parser = GoSumParser; + + assert!( + parser.is_lockfile_stale(&lockfile_path, old_time), + "Lock file should be stale when last_modified is old" + ); + } + + #[test] + fn test_is_lockfile_stale_deleted() { + let parser = GoSumParser; + let non_existent = std::path::Path::new("/nonexistent/go.sum"); + + assert!( + parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()), + "Non-existent lock file should be considered stale" + ); + } + + #[test] + fn test_is_lockfile_stale_future_time() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("go.sum"); + std::fs::write(&lockfile_path, "").unwrap(); + + // Use a time far in the future + let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); // +1 day + let parser = GoSumParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, future_time), + "Lock file should not be stale when last_modified is in the future" + ); + } + + #[test] + fn test_parse_go_sum_with_checksum() { + let content = + "github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=\n"; + let packages = parse_go_sum(content); + + let pkg = packages.get("github.com/gin-gonic/gin").unwrap(); + assert_eq!(pkg.version, "v1.9.1"); + + match &pkg.source { + ResolvedSource::Registry { url, checksum } => { + assert_eq!(url, "https://proxy.golang.org"); + assert_eq!(checksum, "h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg="); + } + _ => panic!("Expected Registry source"), + } + } + + #[test] + fn test_parse_go_sum_dependencies_empty() { + let content = "github.com/gin-gonic/gin v1.9.1 h1:hash=\n"; + let packages = parse_go_sum(content); + + let pkg = packages.get("github.com/gin-gonic/gin").unwrap(); + assert!(pkg.dependencies.is_empty()); + } +} diff --git a/crates/deps-go/src/parser.rs b/crates/deps-go/src/parser.rs new file mode 100644 index 00000000..c92ed68d --- /dev/null +++ b/crates/deps-go/src/parser.rs @@ -0,0 +1,548 @@ +//! go.mod parser with position tracking. +//! +//! Parses go.mod files using regex patterns and line-by-line parsing. +//! Critical for LSP features like hover, completion, and inlay hints. +//! +//! # Key Features +//! +//! - Position-preserving parsing with byte-to-LSP conversion +//! - Handles go.mod directives: module, go, require, replace, exclude +//! - Supports multi-line blocks and inline/block comments +//! - Extracts indirect dependency markers (// indirect) +//! - Note: retract directive is defined in types but not yet parsed + +use crate::error::Result; +use crate::types::{GoDependency, GoDirective}; +use once_cell::sync::Lazy; +use regex::Regex; +use tower_lsp_server::ls_types::{Position, Range, Uri}; + +/// Result of parsing a go.mod file. +#[derive(Debug, Clone, serde::Serialize)] +pub struct GoParseResult { + /// All dependencies found in the file + pub dependencies: Vec, + /// Module path declared in `module` directive + pub module_path: Option, + /// Minimum Go version from `go` directive + pub go_version: Option, + /// Document URI + pub uri: Uri, +} + +/// Pre-computed line start byte offsets for O(log n) position lookups. +struct LineOffsetTable { + line_starts: Vec, +} + +impl LineOffsetTable { + fn new(content: &str) -> Self { + let mut line_starts = vec![0]; + for (i, c) in content.char_indices() { + if c == '\n' { + line_starts.push(i + 1); + } + } + Self { line_starts } + } + + /// Converts byte offset to LSP Position (line, UTF-16 character). + fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position { + let line = self + .line_starts + .partition_point(|&start| start <= offset) + .saturating_sub(1); + let line_start = self.line_starts[line]; + + let character = content[line_start..offset] + .chars() + .map(|c| c.len_utf16() as u32) + .sum(); + + Position::new(line as u32, character) + } +} + +/// Parses a go.mod file and extracts all dependencies with positions. +pub fn parse_go_mod(content: &str, doc_uri: &Uri) -> Result { + tracing::debug!(uri = ?doc_uri, "Parsing go.mod file"); + + let line_table = LineOffsetTable::new(content); + let mut dependencies = Vec::with_capacity(50); + let mut module_path = None; + let mut go_version = None; + + static MODULE_PATTERN: Lazy = Lazy::new(|| Regex::new(r"^\s*module\s+(\S+)").unwrap()); + static GO_PATTERN: Lazy = Lazy::new(|| Regex::new(r"^\s*go\s+(\S+)").unwrap()); + static REQUIRE_SINGLE: Lazy = + Lazy::new(|| Regex::new(r"^\s*require\s+(\S+)\s+(\S+)").unwrap()); + static REQUIRE_BLOCK_START: Lazy = + Lazy::new(|| Regex::new(r"^\s*require\s*\(").unwrap()); + static REPLACE_PATTERN: Lazy = + Lazy::new(|| Regex::new(r"^\s*replace\s+(\S+)\s+(?:(\S+)\s+)?=>\s+(\S+)\s+(\S+)").unwrap()); + static EXCLUDE_PATTERN: Lazy = + Lazy::new(|| Regex::new(r"^\s*exclude\s+(\S+)\s+(\S+)").unwrap()); + + let mut in_require_block = false; + let mut line_offset = 0; + + for line in content.lines() { + let line_without_comment = strip_line_comment(line); + let line_trimmed = line_without_comment.trim(); + + if let Some(caps) = MODULE_PATTERN.captures(line_trimmed) { + module_path = Some(caps[1].to_string()); + } + + if let Some(caps) = GO_PATTERN.captures(line_trimmed) { + go_version = Some(caps[1].to_string()); + } + + if REQUIRE_BLOCK_START.is_match(line_trimmed) { + in_require_block = true; + line_offset += line.len() + 1; + continue; + } + + if in_require_block && line_trimmed.contains(')') { + in_require_block = false; + line_offset += line.len() + 1; + continue; + } + + if (in_require_block || REQUIRE_SINGLE.is_match(line_trimmed)) + && let Some(dep) = parse_require_line(line, line_offset, content, &line_table) + { + dependencies.push(dep); + } + + if let Some(caps) = REPLACE_PATTERN.captures(line_trimmed) { + let module = &caps[1]; + let version = caps.get(2).map(|m| m.as_str()); + if let Some(dep) = + parse_replace_line(line, line_offset, module, version, content, &line_table) + { + dependencies.push(dep); + } + } + + if let Some(caps) = EXCLUDE_PATTERN.captures(line_trimmed) { + let module = &caps[1]; + let version = &caps[2]; + if let Some(dep) = + parse_exclude_line(line, line_offset, module, version, content, &line_table) + { + dependencies.push(dep); + } + } + + let line_end = line_offset + line.len(); + let next_line_start = if line_end < content.len() && content.as_bytes()[line_end] == b'\n' { + line_end + 1 + } else { + line_end + }; + line_offset = next_line_start; + } + + tracing::debug!( + dependencies = %dependencies.len(), + module = ?module_path, + go_version = ?go_version, + "Parsed go.mod successfully" + ); + + Ok(GoParseResult { + dependencies, + module_path, + go_version, + uri: doc_uri.clone(), + }) +} + +/// Strips line comments from a line (everything after //). +/// +/// Handles URL schemes (e.g., https://) to avoid stripping URL paths. +fn strip_line_comment(line: &str) -> &str { + let mut in_url = false; + for (i, c) in line.char_indices() { + if c == ':' && line[i..].starts_with("://") { + in_url = true; + continue; + } + if in_url && c.is_whitespace() { + in_url = false; + } + if !in_url && line[i..].starts_with("//") { + return &line[..i]; + } + } + line +} + +/// Parses a single require line. +fn parse_require_line( + line: &str, + line_start_offset: usize, + content: &str, + line_table: &LineOffsetTable, +) -> Option { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + + let (module_path, version) = if parts[0] == "require" { + if parts.len() < 3 { + return None; + } + (parts[1], parts[2]) + } else { + if parts.len() < 2 { + return None; + } + (parts[0], parts[1]) + }; + + let indirect = line.contains("// indirect"); + + let module_start = line.find(module_path)?; + let module_offset = line_start_offset + module_start; + let module_path_range = Range::new( + line_table.byte_offset_to_position(content, module_offset), + line_table.byte_offset_to_position(content, module_offset + module_path.len()), + ); + + let version_start = line.find(version)?; + let version_offset = line_start_offset + version_start; + let version_range = Range::new( + line_table.byte_offset_to_position(content, version_offset), + line_table.byte_offset_to_position(content, version_offset + version.len()), + ); + + Some(GoDependency { + module_path: module_path.to_string(), + module_path_range, + version: Some(version.to_string()), + version_range: Some(version_range), + directive: GoDirective::Require, + indirect, + }) +} + +/// Parses a replace directive line. +fn parse_replace_line( + line: &str, + line_start_offset: usize, + module: &str, + version: Option<&str>, + content: &str, + line_table: &LineOffsetTable, +) -> Option { + let module_start = line.find(module)?; + let module_offset = line_start_offset + module_start; + let module_path_range = Range::new( + line_table.byte_offset_to_position(content, module_offset), + line_table.byte_offset_to_position(content, module_offset + module.len()), + ); + + let (version_str, version_range) = if let Some(ver) = version { + let version_start = line.find(ver)?; + let version_offset = line_start_offset + version_start; + let range = Range::new( + line_table.byte_offset_to_position(content, version_offset), + line_table.byte_offset_to_position(content, version_offset + ver.len()), + ); + (Some(ver.to_string()), Some(range)) + } else { + (None, None) + }; + + Some(GoDependency { + module_path: module.to_string(), + module_path_range, + version: version_str, + version_range, + directive: GoDirective::Replace, + indirect: false, + }) +} + +/// Parses an exclude directive line. +fn parse_exclude_line( + line: &str, + line_start_offset: usize, + module: &str, + version: &str, + content: &str, + line_table: &LineOffsetTable, +) -> Option { + let module_start = line.find(module)?; + let module_offset = line_start_offset + module_start; + let module_path_range = Range::new( + line_table.byte_offset_to_position(content, module_offset), + line_table.byte_offset_to_position(content, module_offset + module.len()), + ); + + let version_start = line.find(version)?; + let version_offset = line_start_offset + version_start; + let version_range = Range::new( + line_table.byte_offset_to_position(content, version_offset), + line_table.byte_offset_to_position(content, version_offset + version.len()), + ); + + Some(GoDependency { + module_path: module.to_string(), + module_path_range, + version: Some(version.to_string()), + version_range: Some(version_range), + directive: GoDirective::Exclude, + indirect: false, + }) +} + +impl deps_core::parser::ParseResultInfo for GoParseResult { + type Dependency = GoDependency; + + fn dependencies(&self) -> &[Self::Dependency] { + &self.dependencies + } + + fn workspace_root(&self) -> Option<&std::path::Path> { + None + } +} + +deps_core::impl_parse_result!( + GoParseResult, + GoDependency { + dependencies: dependencies, + uri: uri, + } +); + +#[cfg(test)] +mod tests { + use super::*; + fn test_uri() -> Uri { + use std::str::FromStr; + Uri::from_str("file:///test/go.mod").unwrap() + } + + #[test] + fn test_parse_single_require() { + let content = r#"module example.com/myapp + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +"#; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies[0].module_path, + "github.com/gin-gonic/gin" + ); + assert_eq!(result.dependencies[0].version, Some("v1.9.1".to_string())); + assert!(!result.dependencies[0].indirect); + } + + #[test] + fn test_parse_module_directive() { + let content = "module example.com/myapp\n"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.module_path, Some("example.com/myapp".to_string())); + } + + #[test] + fn test_parse_go_version() { + let content = "go 1.21\n"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.go_version, Some("1.21".to_string())); + } + + #[test] + fn test_parse_require_block() { + let content = r#"require ( + github.com/gin-gonic/gin v1.9.1 + golang.org/x/crypto v0.17.0 // indirect +) +"#; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 2); + assert!(!result.dependencies[0].indirect); + assert!(result.dependencies[1].indirect); + } + + #[test] + fn test_parse_replace_directive() { + let content = "replace github.com/old/module => github.com/new/module v1.2.3\n"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + assert_eq!(result.dependencies[0].directive, GoDirective::Replace); + assert_eq!(result.dependencies[0].module_path, "github.com/old/module"); + } + + #[test] + fn test_parse_exclude_directive() { + let content = "exclude github.com/bad/module v0.1.0\n"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + assert_eq!(result.dependencies[0].directive, GoDirective::Exclude); + assert_eq!(result.dependencies[0].module_path, "github.com/bad/module"); + assert_eq!(result.dependencies[0].version, Some("v0.1.0".to_string())); + } + + #[test] + fn test_parse_pseudo_version() { + let content = "require golang.org/x/crypto v0.0.0-20191109021931-daa7c04131f5\n"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!( + result.dependencies[0].version, + Some("v0.0.0-20191109021931-daa7c04131f5".to_string()) + ); + } + + #[test] + fn test_position_tracking() { + let content = "require github.com/gin-gonic/gin v1.9.1"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + let dep = &result.dependencies[0]; + + assert_eq!(dep.module_path_range.start.line, 0); + assert!(dep.version_range.is_some()); + } + + #[test] + fn test_empty_file() { + let content = ""; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 0); + assert_eq!(result.module_path, None); + assert_eq!(result.go_version, None); + } + + #[test] + fn test_comments_stripped() { + let content = + "// This is a comment\nrequire github.com/pkg/errors v0.9.1 // inline comment\n"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 1); + assert_eq!(result.dependencies[0].module_path, "github.com/pkg/errors"); + } + + #[test] + fn test_complex_go_mod() { + let content = r#"module example.com/myapp + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + golang.org/x/crypto v0.17.0 // indirect +) + +replace github.com/old/module => github.com/new/module v1.2.3 + +exclude github.com/bad/module v0.1.0 +"#; + let result = parse_go_mod(content, &test_uri()).unwrap(); + assert_eq!(result.dependencies.len(), 4); + assert_eq!(result.module_path, Some("example.com/myapp".to_string())); + assert_eq!(result.go_version, Some("1.21".to_string())); + + let require_deps: Vec<_> = result + .dependencies + .iter() + .filter(|d| d.directive == GoDirective::Require) + .collect(); + assert_eq!(require_deps.len(), 2); + + let replace_deps: Vec<_> = result + .dependencies + .iter() + .filter(|d| d.directive == GoDirective::Replace) + .collect(); + assert_eq!(replace_deps.len(), 1); + + let exclude_deps: Vec<_> = result + .dependencies + .iter() + .filter(|d| d.directive == GoDirective::Exclude) + .collect(); + assert_eq!(exclude_deps.len(), 1); + } + + #[test] + fn test_position_tracking_no_trailing_newline() { + let content = "require github.com/gin-gonic/gin v1.9.1"; + let result = parse_go_mod(content, &test_uri()).unwrap(); + let dep = &result.dependencies[0]; + + assert_eq!(dep.module_path_range.start.character, 8); + assert_eq!(dep.module_path_range.end.character, 32); + assert_eq!(dep.version_range.as_ref().unwrap().start.character, 33); + assert_eq!(dep.version_range.as_ref().unwrap().end.character, 39); + } + + #[test] + fn test_parse_complex_go_mod() { + let content = r#"module example.com/myapp + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + golang.org/x/crypto v0.17.0 // indirect +) + +replace github.com/old/module => github.com/new/module v1.2.3 + +exclude github.com/bad/module v0.1.0 +"#; + let result = parse_go_mod(content, &test_uri()).unwrap(); + + // Check module metadata + assert_eq!(result.module_path, Some("example.com/myapp".to_string())); + assert_eq!(result.go_version, Some("1.21".to_string())); + + // Check dependencies count + assert_eq!(result.dependencies.len(), 4); + + // Check gin-gonic (require, direct) + let gin = &result.dependencies[0]; + assert_eq!(gin.module_path, "github.com/gin-gonic/gin"); + assert_eq!(gin.version, Some("v1.9.1".to_string())); + assert_eq!(gin.directive, GoDirective::Require); + assert!(!gin.indirect); + + // Check crypto (require, indirect) + let crypto = &result.dependencies[1]; + assert_eq!(crypto.module_path, "golang.org/x/crypto"); + assert_eq!(crypto.version, Some("v0.17.0".to_string())); + assert_eq!(crypto.directive, GoDirective::Require); + assert!(crypto.indirect); + + // Check replace directive + let replace = &result.dependencies[2]; + assert_eq!(replace.module_path, "github.com/old/module"); + assert_eq!(replace.version, None); + assert_eq!(replace.directive, GoDirective::Replace); + + // Check exclude directive + let exclude = &result.dependencies[3]; + assert_eq!(exclude.module_path, "github.com/bad/module"); + assert_eq!(exclude.version, Some("v0.1.0".to_string())); + assert_eq!(exclude.directive, GoDirective::Exclude); + } + + #[test] + fn test_strip_line_comment_with_url() { + let line = "replace github.com/old => https://github.com/new // comment"; + let stripped = strip_line_comment(line); + assert_eq!( + stripped, + "replace github.com/old => https://github.com/new " + ); + } +} diff --git a/crates/deps-go/src/registry.rs b/crates/deps-go/src/registry.rs new file mode 100644 index 00000000..ac0fe3e8 --- /dev/null +++ b/crates/deps-go/src/registry.rs @@ -0,0 +1,651 @@ +//! proxy.golang.org registry client. +//! +//! Provides access to Go module proxy via: +//! - `/{module}/@v/list` - list all versions +//! - `/{module}/@v/{version}.info` - version metadata +//! - `/{module}/@v/{version}.mod` - go.mod file +//! - `/{module}/@latest` - latest version info +//! +//! All HTTP requests are cached aggressively using ETag/Last-Modified headers. +//! +//! # Examples +//! +//! ```no_run +//! use deps_go::GoRegistry; +//! use deps_core::HttpCache; +//! use std::sync::Arc; +//! +//! #[tokio::main] +//! async fn main() { +//! let cache = Arc::new(HttpCache::new()); +//! let registry = GoRegistry::new(cache); +//! +//! let versions = registry.get_versions("github.com/gin-gonic/gin").await.unwrap(); +//! println!("Latest gin: {}", versions[0].version); +//! } +//! ``` + +use crate::error::{GoError, Result}; +use crate::types::GoVersion; +use crate::version::{escape_module_path, is_pseudo_version}; +use deps_core::HttpCache; +use serde::Deserialize; +use std::any::Any; +use std::sync::Arc; + +const PROXY_BASE: &str = "https://proxy.golang.org"; + +/// Base URL for Go package documentation +pub const PKG_GO_DEV_URL: &str = "https://pkg.go.dev"; + +/// Maximum allowed module path length to prevent DoS +const MAX_MODULE_PATH_LENGTH: usize = 500; + +/// Maximum allowed version string length +const MAX_VERSION_LENGTH: usize = 128; + +/// Validates a module path for length and basic format. +/// +/// # Errors +/// +/// Returns error if: +/// - Path is empty +/// - Path exceeds MAX_MODULE_PATH_LENGTH +fn validate_module_path(module_path: &str) -> Result<()> { + if module_path.is_empty() { + return Err(GoError::InvalidModulePath("module path is empty".into())); + } + + if module_path.len() > MAX_MODULE_PATH_LENGTH { + return Err(GoError::InvalidModulePath(format!( + "module path exceeds maximum length of {} characters", + MAX_MODULE_PATH_LENGTH + ))); + } + + Ok(()) +} + +/// Validates a version string for length and basic format. +/// +/// # Errors +/// +/// Returns error if: +/// - Version is empty +/// - Version exceeds MAX_VERSION_LENGTH +/// - Version contains path traversal sequences +fn validate_version_string(version: &str) -> Result<()> { + if version.is_empty() { + return Err(GoError::InvalidVersionSpecifier { + specifier: version.to_string(), + message: "version string is empty".into(), + }); + } + + if version.len() > MAX_VERSION_LENGTH { + return Err(GoError::InvalidVersionSpecifier { + specifier: version.to_string(), + message: format!( + "version string exceeds maximum length of {} characters", + MAX_VERSION_LENGTH + ), + }); + } + + // Check for path traversal attempts + if version.contains("..") || version.contains('/') || version.contains('\\') { + return Err(GoError::InvalidVersionSpecifier { + specifier: version.to_string(), + message: "version string contains invalid characters".into(), + }); + } + + Ok(()) +} + +/// Returns the URL for a module's documentation page on pkg.go.dev. +pub fn package_url(module_path: &str) -> String { + format!("{}/{}", PKG_GO_DEV_URL, module_path) +} + +/// Client for interacting with proxy.golang.org. +/// +/// Uses the Go module proxy protocol for version lookups and metadata. +/// All requests are cached via the provided HttpCache. +#[derive(Clone)] +pub struct GoRegistry { + cache: Arc, +} + +impl GoRegistry { + /// Creates a new Go registry client with the given HTTP cache. + pub fn new(cache: Arc) -> Self { + Self { cache } + } + + /// Fetches all versions for a module from the `/@v/list` endpoint. + /// + /// Returns versions in registry order (not sorted). Includes pseudo-versions. + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - Response body is invalid UTF-8 + /// - Module does not exist (404) + /// - Module path is invalid or too long + /// + /// # Examples + /// + /// ```no_run + /// # use deps_go::GoRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = GoRegistry::new(cache); + /// + /// let versions = registry.get_versions("github.com/gin-gonic/gin").await.unwrap(); + /// assert!(!versions.is_empty()); + /// # } + /// ``` + pub async fn get_versions(&self, module_path: &str) -> Result> { + validate_module_path(module_path)?; + + let escaped = escape_module_path(module_path); + let url = format!("{}/{}/@v/list", PROXY_BASE, escaped); + + let data = self + .cache + .get_cached(&url) + .await + .map_err(|e| GoError::RegistryError { + module: module_path.to_string(), + source: Box::new(e), + })?; + + parse_version_list(&data) + } + + /// Fetches version metadata from the `/@v/{version}.info` endpoint. + /// + /// Returns version with timestamp information. + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - JSON parsing fails + /// - Module path or version string is invalid + /// + /// # Examples + /// + /// ```no_run + /// # use deps_go::GoRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = GoRegistry::new(cache); + /// + /// let info = registry.get_version_info("github.com/gin-gonic/gin", "v1.9.1").await.unwrap(); + /// assert_eq!(info.version, "v1.9.1"); + /// # } + /// ``` + pub async fn get_version_info(&self, module_path: &str, version: &str) -> Result { + validate_module_path(module_path)?; + validate_version_string(version)?; + + let escaped = escape_module_path(module_path); + let url = format!("{}/{}/@v/{}.info", PROXY_BASE, escaped, version); + + let data = self + .cache + .get_cached(&url) + .await + .map_err(|e| GoError::RegistryError { + module: module_path.to_string(), + source: Box::new(e), + })?; + + parse_version_info(&data) + } + + /// Fetches latest version using the `/@latest` endpoint. + /// + /// Returns the latest stable version (non-pseudo). + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - JSON parsing fails + /// - Module path is invalid + /// + /// # Examples + /// + /// ```no_run + /// # use deps_go::GoRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = GoRegistry::new(cache); + /// + /// let latest = registry.get_latest("github.com/gin-gonic/gin").await.unwrap(); + /// assert!(!latest.is_pseudo); + /// # } + /// ``` + pub async fn get_latest(&self, module_path: &str) -> Result { + validate_module_path(module_path)?; + + let escaped = escape_module_path(module_path); + let url = format!("{}/{}/@latest", PROXY_BASE, escaped); + + let data = self + .cache + .get_cached(&url) + .await + .map_err(|e| GoError::RegistryError { + module: module_path.to_string(), + source: Box::new(e), + })?; + + parse_version_info(&data) + } + + /// Fetches the go.mod file for a specific version. + /// + /// Returns the raw content of the go.mod file. + /// + /// # Errors + /// + /// Returns an error if: + /// - HTTP request fails + /// - Response body is invalid UTF-8 + /// - Module path or version string is invalid + /// + /// # Examples + /// + /// ```no_run + /// # use deps_go::GoRegistry; + /// # use deps_core::HttpCache; + /// # use std::sync::Arc; + /// # #[tokio::main] + /// # async fn main() { + /// let cache = Arc::new(HttpCache::new()); + /// let registry = GoRegistry::new(cache); + /// + /// let go_mod = registry.get_go_mod("github.com/gin-gonic/gin", "v1.9.1").await.unwrap(); + /// assert!(go_mod.contains("module github.com/gin-gonic/gin")); + /// # } + /// ``` + pub async fn get_go_mod(&self, module_path: &str, version: &str) -> Result { + validate_module_path(module_path)?; + validate_version_string(version)?; + + let escaped = escape_module_path(module_path); + let url = format!("{}/{}/@v/{}.mod", PROXY_BASE, escaped, version); + + let data = self + .cache + .get_cached(&url) + .await + .map_err(|e| GoError::RegistryError { + module: module_path.to_string(), + source: Box::new(e), + })?; + + std::str::from_utf8(&data) + .map(|s| s.to_string()) + .map_err(|e| GoError::CacheError(format!("Invalid UTF-8 in go.mod: {}", e))) + } +} + +/// Version info response from proxy.golang.org. +#[derive(Deserialize)] +struct VersionInfo { + #[serde(rename = "Version")] + version: String, + #[serde(rename = "Time")] + time: String, +} + +/// Parses newline-separated version list from `/@v/list` endpoint. +fn parse_version_list(data: &[u8]) -> Result> { + let content = std::str::from_utf8(data).map_err(|e| GoError::InvalidVersionSpecifier { + specifier: String::new(), + message: format!("Invalid UTF-8 in version list response: {}", e), + })?; + + let versions: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| GoVersion { + version: line.to_string(), + time: None, + is_pseudo: is_pseudo_version(line), + retracted: false, // Would need to check .info for retraction + }) + .collect(); + + Ok(versions) +} + +/// Parses JSON version info from `/@v/{version}.info` or `/@latest` endpoint. +fn parse_version_info(data: &[u8]) -> Result { + let info: VersionInfo = + serde_json::from_slice(data).map_err(|e| GoError::ApiResponseError { + module: String::new(), + source: e, + })?; + + let is_pseudo = is_pseudo_version(&info.version); + Ok(GoVersion { + version: info.version, + time: Some(info.time), + is_pseudo, + retracted: false, + }) +} + +// Implement deps_core::Registry trait for trait object support +#[async_trait::async_trait] +impl deps_core::Registry for GoRegistry { + async fn get_versions( + &self, + name: &str, + ) -> deps_core::Result>> { + let versions = self.get_versions(name).await?; + Ok(versions + .into_iter() + .map(|v| Box::new(v) as Box) + .collect()) + } + + async fn get_latest_matching( + &self, + name: &str, + _req: &str, + ) -> deps_core::Result>> { + // Go doesn't support version requirements in proxy API + // Just return latest stable (non-pseudo) version + let versions = self.get_versions(name).await?; + let latest = versions.into_iter().find(|v| !v.is_pseudo && !v.retracted); + + Ok(latest.map(|v| Box::new(v) as Box)) + } + + async fn search( + &self, + _query: &str, + _limit: usize, + ) -> deps_core::Result>> { + // proxy.golang.org doesn't support search + // Could integrate with pkg.go.dev API in future + Ok(vec![]) + } + + fn package_url(&self, name: &str) -> String { + package_url(name) + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_version_list() { + let data = b"v1.0.0\nv1.0.1\nv1.1.0\nv2.0.0\n"; + + let versions = parse_version_list(data).unwrap(); + assert_eq!(versions.len(), 4); + assert_eq!(versions[0].version, "v1.0.0"); + assert_eq!(versions[1].version, "v1.0.1"); + assert!(!versions[0].is_pseudo); + } + + #[test] + fn test_parse_version_list_with_pseudo() { + let data = b"v1.0.0\nv0.0.0-20191109021931-daa7c04131f5\nv1.1.0\n"; + + let versions = parse_version_list(data).unwrap(); + assert_eq!(versions.len(), 3); + assert!(!versions[0].is_pseudo); + assert!(versions[1].is_pseudo); + assert!(!versions[2].is_pseudo); + } + + #[test] + fn test_parse_version_list_empty() { + let data = b""; + let versions = parse_version_list(data).unwrap(); + assert_eq!(versions.len(), 0); + } + + #[test] + fn test_parse_version_list_blank_lines() { + let data = b"\n\n\n"; + let versions = parse_version_list(data).unwrap(); + assert_eq!(versions.len(), 0); + } + + #[test] + fn test_parse_version_info() { + let json = r#"{"Version":"v1.9.1","Time":"2023-07-18T14:30:00Z"}"#; + let version = parse_version_info(json.as_bytes()).unwrap(); + assert_eq!(version.version, "v1.9.1"); + assert_eq!(version.time, Some("2023-07-18T14:30:00Z".into())); + assert!(!version.is_pseudo); + } + + #[test] + fn test_parse_version_info_pseudo() { + let json = + r#"{"Version":"v0.0.0-20191109021931-daa7c04131f5","Time":"2019-11-09T02:19:31Z"}"#; + let version = parse_version_info(json.as_bytes()).unwrap(); + assert_eq!(version.version, "v0.0.0-20191109021931-daa7c04131f5"); + assert!(version.is_pseudo); + } + + #[test] + fn test_parse_version_info_invalid_json() { + let json = b"not json"; + let result = parse_version_info(json); + assert!(result.is_err()); + } + + #[test] + fn test_package_url() { + assert_eq!( + package_url("github.com/gin-gonic/gin"), + "https://pkg.go.dev/github.com/gin-gonic/gin" + ); + assert_eq!( + package_url("golang.org/x/crypto"), + "https://pkg.go.dev/golang.org/x/crypto" + ); + } + + #[tokio::test] + async fn test_registry_creation() { + let cache = Arc::new(HttpCache::new()); + let _registry = GoRegistry::new(cache); + } + + #[tokio::test] + async fn test_registry_clone() { + let cache = Arc::new(HttpCache::new()); + let registry = GoRegistry::new(cache); + let _cloned = registry.clone(); + } + + #[tokio::test] + #[ignore] + async fn test_fetch_real_gin_versions() { + let cache = Arc::new(HttpCache::new()); + let registry = GoRegistry::new(cache); + let versions = registry + .get_versions("github.com/gin-gonic/gin") + .await + .unwrap(); + + assert!(!versions.is_empty()); + assert!(versions.iter().any(|v| v.version.starts_with("v1."))); + } + + #[tokio::test] + #[ignore] + async fn test_fetch_real_version_info() { + let cache = Arc::new(HttpCache::new()); + let registry = GoRegistry::new(cache); + let info = registry + .get_version_info("github.com/gin-gonic/gin", "v1.9.1") + .await + .unwrap(); + + assert_eq!(info.version, "v1.9.1"); + assert!(info.time.is_some()); + } + + #[tokio::test] + #[ignore] + async fn test_fetch_real_latest() { + let cache = Arc::new(HttpCache::new()); + let registry = GoRegistry::new(cache); + let latest = registry + .get_latest("github.com/gin-gonic/gin") + .await + .unwrap(); + + assert!(latest.version.starts_with("v")); + assert!(!latest.is_pseudo); + } + + #[tokio::test] + #[ignore] + async fn test_fetch_real_go_mod() { + let cache = Arc::new(HttpCache::new()); + let registry = GoRegistry::new(cache); + let go_mod = registry + .get_go_mod("github.com/gin-gonic/gin", "v1.9.1") + .await + .unwrap(); + + assert!(go_mod.contains("module github.com/gin-gonic/gin")); + } + + #[tokio::test] + #[ignore] + async fn test_module_not_found() { + let cache = Arc::new(HttpCache::new()); + let registry = GoRegistry::new(cache); + let result = registry + .get_versions("github.com/nonexistent/module12345") + .await; + assert!(result.is_err()); + } + + #[test] + fn test_parse_version_list_mixed_stable_and_pseudo() { + let data = b"v1.0.0\nv1.1.0-0.20200101000000-abcdefabcdef\nv1.2.0\nv1.2.1-beta.1\n"; + let versions = parse_version_list(data).unwrap(); + assert_eq!(versions.len(), 4); + assert!(!versions[0].is_pseudo); // v1.0.0 + assert!(versions[1].is_pseudo); // pseudo-version + assert!(!versions[2].is_pseudo); // v1.2.0 + assert!(!versions[3].is_pseudo); // v1.2.1-beta.1 (prerelease, not pseudo) + } + + #[test] + fn test_parse_version_list_invalid_utf8() { + let data = &[0xFF, 0xFE, 0xFD]; // Invalid UTF-8 + let result = parse_version_list(data); + assert!(result.is_err()); + } + + #[test] + fn test_parse_version_info_missing_fields() { + let json = r#"{"Version":"v1.0.0"}"#; // Missing Time field + let result = parse_version_info(json.as_bytes()); + assert!(result.is_err()); + } + + #[test] + fn test_validate_module_path_empty() { + let result = validate_module_path(""); + assert!(result.is_err()); + assert!(matches!(result, Err(GoError::InvalidModulePath(_)))); + } + + #[test] + fn test_validate_module_path_too_long() { + let long_path = "a".repeat(MAX_MODULE_PATH_LENGTH + 1); + let result = validate_module_path(&long_path); + assert!(result.is_err()); + assert!(matches!(result, Err(GoError::InvalidModulePath(_)))); + } + + #[test] + fn test_validate_module_path_valid() { + let result = validate_module_path("github.com/user/repo"); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_version_string_empty() { + let result = validate_version_string(""); + assert!(result.is_err()); + assert!(matches!( + result, + Err(GoError::InvalidVersionSpecifier { .. }) + )); + } + + #[test] + fn test_validate_version_string_too_long() { + let long_version = "v".to_string() + &"1".repeat(MAX_VERSION_LENGTH); + let result = validate_version_string(&long_version); + assert!(result.is_err()); + assert!(matches!( + result, + Err(GoError::InvalidVersionSpecifier { .. }) + )); + } + + #[test] + fn test_validate_version_string_path_traversal() { + let result = validate_version_string("v1.0.0/../etc/passwd"); + assert!(result.is_err()); + assert!(matches!( + result, + Err(GoError::InvalidVersionSpecifier { .. }) + )); + } + + #[test] + fn test_validate_version_string_slashes() { + let result = validate_version_string("v1.0.0/malicious"); + assert!(result.is_err()); + + let result = validate_version_string("v1.0.0\\malicious"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_version_string_valid() { + let result = validate_version_string("v1.0.0"); + assert!(result.is_ok()); + + let result = validate_version_string("v0.0.0-20191109021931-daa7c04131f5"); + assert!(result.is_ok()); + } +} diff --git a/crates/deps-go/src/types.rs b/crates/deps-go/src/types.rs new file mode 100644 index 00000000..159e14da --- /dev/null +++ b/crates/deps-go/src/types.rs @@ -0,0 +1,252 @@ +//! Types for Go module dependency management. + +use deps_core::parser::DependencySource; +use std::any::Any; +use tower_lsp_server::ls_types::Range; + +/// A dependency from a go.mod file. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct GoDependency { + /// Module path (e.g., "github.com/gin-gonic/gin") + pub module_path: String, + /// LSP range of the module path in source + pub module_path_range: Range, + /// Version requirement (e.g., "v1.9.1", "v0.0.0-20191109021931-daa7c04131f5") + pub version: Option, + /// LSP range of version in source + pub version_range: Option, + /// Dependency directive type + pub directive: GoDirective, + /// Whether this is an indirect dependency (// indirect comment) + pub indirect: bool, +} + +/// Go module directive types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)] +pub enum GoDirective { + /// Direct dependency in require block + Require, + /// Replacement directive + Replace, + /// Exclusion directive + Exclude, + /// Retraction directive + Retract, +} + +/// Version information from proxy.golang.org. +#[derive(Debug, Clone)] +pub struct GoVersion { + /// Version string (e.g., "v1.9.1") + pub version: String, + /// Timestamp when version was published + pub time: Option, + /// Whether this is a pseudo-version + pub is_pseudo: bool, + /// Whether this version is retracted + pub retracted: bool, +} + +/// Package metadata from proxy.golang.org. +#[derive(Debug, Clone)] +pub struct GoMetadata { + /// Module path + pub module_path: String, + /// Latest stable version + pub latest_version: String, + /// Description (if available from go.mod or README) + pub description: Option, + /// Repository URL (inferred from module path) + pub repository: Option, + /// Documentation URL (pkg.go.dev) + pub documentation: Option, +} + +// NOTE: Cannot use deps_core::impl_dependency! macro because we need to provide custom +// features() implementation (Go modules don't have features like Cargo). +// The macro would provide features() but we need to override it anyway. +impl deps_core::parser::DependencyInfo for GoDependency { + fn name(&self) -> &str { + &self.module_path + } + + fn name_range(&self) -> Range { + self.module_path_range + } + + fn version_requirement(&self) -> Option<&str> { + self.version.as_deref() + } + + fn version_range(&self) -> Option { + self.version_range + } + + fn source(&self) -> DependencySource { + DependencySource::Registry + } + + fn features(&self) -> &[String] { + &[] + } +} + +impl deps_core::ecosystem::Dependency for GoDependency { + fn name(&self) -> &str { + &self.module_path + } + + fn name_range(&self) -> Range { + self.module_path_range + } + + fn version_requirement(&self) -> Option<&str> { + self.version.as_deref() + } + + fn version_range(&self) -> Option { + self.version_range + } + + fn source(&self) -> DependencySource { + DependencySource::Registry + } + + fn features(&self) -> &[String] { + &[] + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +// NOTE: Cannot use impl_version! macro because GoVersion has custom is_prerelease() logic. +// Go considers pseudo-versions as pre-releases, and has special handling for +incompatible suffix. +impl deps_core::registry::Version for GoVersion { + fn version_string(&self) -> &str { + &self.version + } + + fn is_yanked(&self) -> bool { + self.retracted + } + + fn is_prerelease(&self) -> bool { + // Go considers pseudo-versions as pre-releases (they're commit-based). + // Regular pre-releases contain '-' (e.g., v1.0.0-beta.1). + // BUT: +incompatible suffix is NOT a pre-release indicator. + self.is_pseudo || (self.version.contains('-') && !self.version.contains("+incompatible")) + } + + fn features(&self) -> Vec { + vec![] + } + + fn as_any(&self) -> &dyn Any { + self + } +} + +deps_core::impl_metadata!(GoMetadata { + name: module_path, + description: description, + repository: repository, + documentation: documentation, + latest_version: latest_version, +}); + +#[cfg(test)] +mod tests { + use super::*; + use deps_core::parser::DependencyInfo; + use deps_core::registry::{Metadata, Version}; + use tower_lsp_server::ls_types::Position; + + #[test] + fn test_go_dependency_trait() { + let dep = GoDependency { + module_path: "github.com/gin-gonic/gin".to_string(), + module_path_range: Range::new(Position::new(0, 0), Position::new(0, 10)), + version: Some("v1.9.1".to_string()), + version_range: Some(Range::new(Position::new(0, 11), Position::new(0, 17))), + directive: GoDirective::Require, + indirect: false, + }; + + assert_eq!(dep.name(), "github.com/gin-gonic/gin"); + assert_eq!(dep.version_requirement(), Some("v1.9.1")); + assert!(matches!(dep.source(), DependencySource::Registry)); + assert_eq!(dep.features().len(), 0); + } + + #[test] + fn test_go_version_trait() { + let version = GoVersion { + version: "v1.9.1".to_string(), + time: Some("2023-01-01T00:00:00Z".to_string()), + is_pseudo: false, + retracted: false, + }; + + assert_eq!(version.version_string(), "v1.9.1"); + assert!(!version.is_yanked()); + assert!(!version.is_prerelease()); + assert!(version.is_stable()); + } + + #[test] + fn test_pseudo_version_is_prerelease() { + let version = GoVersion { + version: "v0.0.0-20191109021931-daa7c04131f5".to_string(), + time: None, + is_pseudo: true, + retracted: false, + }; + + assert!(version.is_prerelease()); + assert!(!version.is_stable()); + } + + #[test] + fn test_retracted_version_is_yanked() { + let version = GoVersion { + version: "v1.0.0".to_string(), + time: None, + is_pseudo: false, + retracted: true, + }; + + assert!(version.is_yanked()); + assert!(!version.is_stable()); + } + + #[test] + fn test_go_metadata_trait() { + let metadata = GoMetadata { + module_path: "github.com/gin-gonic/gin".to_string(), + latest_version: "v1.9.1".to_string(), + description: Some("Gin is a HTTP web framework".to_string()), + repository: Some("https://github.com/gin-gonic/gin".to_string()), + documentation: Some("https://pkg.go.dev/github.com/gin-gonic/gin".to_string()), + }; + + assert_eq!(metadata.name(), "github.com/gin-gonic/gin"); + assert_eq!(metadata.latest_version(), "v1.9.1"); + assert_eq!(metadata.description(), Some("Gin is a HTTP web framework")); + assert_eq!( + metadata.repository(), + Some("https://github.com/gin-gonic/gin") + ); + assert_eq!( + metadata.documentation(), + Some("https://pkg.go.dev/github.com/gin-gonic/gin") + ); + } + + #[test] + fn test_go_directive_equality() { + assert_eq!(GoDirective::Require, GoDirective::Require); + assert_ne!(GoDirective::Require, GoDirective::Replace); + } +} diff --git a/crates/deps-go/src/version.rs b/crates/deps-go/src/version.rs new file mode 100644 index 00000000..113749e6 --- /dev/null +++ b/crates/deps-go/src/version.rs @@ -0,0 +1,275 @@ +//! Version parsing and module path utilities for Go modules. + +use crate::error::{GoError, Result}; +use once_cell::sync::Lazy; +use regex::Regex; +use std::cmp::Ordering; + +/// Escapes a Go module path for proxy.golang.org API requests. +/// +/// Rules: +/// - Uppercase letters → `!lowercase` (e.g., `User` → `!user`) +/// - Special characters percent-encoded (RFC 3986) +/// +/// # Examples +/// +/// ``` +/// use deps_go::escape_module_path; +/// +/// assert_eq!( +/// escape_module_path("github.com/User/Repo"), +/// "github.com/!user/!repo" +/// ); +/// ``` +pub fn escape_module_path(path: &str) -> String { + let mut result = String::with_capacity(path.len() + 10); + + for c in path.chars() { + if c.is_uppercase() { + result.push('!'); + result.push(c.to_ascii_lowercase()); + } else if c.is_ascii_alphanumeric() + || c == '/' + || c == '-' + || c == '.' + || c == '_' + || c == '~' + { + result.push(c); + } else { + // Encode each byte of the UTF-8 representation + let mut buf = [0u8; 4]; + let encoded = c.encode_utf8(&mut buf); + for &byte in encoded.as_bytes() { + result.push_str(&format!("%{:02X}", byte)); + } + } + } + + result +} + +/// Checks if a version string is a pseudo-version. +/// +/// Pseudo-version format: `vX.Y.Z-yyyymmddhhmmss-abcdefabcdef` +/// +/// # Examples +/// +/// ``` +/// use deps_go::is_pseudo_version; +/// +/// assert!(is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5")); +/// assert!(!is_pseudo_version("v1.2.3")); +/// ``` +pub fn is_pseudo_version(version: &str) -> bool { + static PSEUDO_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^v[0-9]+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+(\+.*)?$").unwrap() + }); + + PSEUDO_REGEX.is_match(version) +} + +/// Extracts the base version from a pseudo-version. +/// +/// # Examples +/// +/// ``` +/// use deps_go::base_version_from_pseudo; +/// +/// assert_eq!( +/// base_version_from_pseudo("v1.2.4-0.20191109021931-daa7c04131f5"), +/// Some("v1.2.3".to_string()) +/// ); +/// ``` +pub fn base_version_from_pseudo(pseudo: &str) -> Option { + if !is_pseudo_version(pseudo) { + return None; + } + + let parts: Vec<&str> = pseudo.split('-').collect(); + if parts.len() < 3 { + return None; + } + + let version_part = parts[0]; + let pre_release_part = parts[1]; + + if pre_release_part.starts_with('0') { + let semver = version_part.strip_prefix('v')?; + let mut components: Vec = semver.split('.').filter_map(|s| s.parse().ok()).collect(); + if components.len() == 3 && components[2] > 0 { + components[2] -= 1; + return Some(format!( + "v{}.{}.{}", + components[0], components[1], components[2] + )); + } + } + + Some(version_part.to_string()) +} + +/// Compares two Go versions using semantic versioning rules. +/// +/// # Pseudo-version Handling +/// +/// Pseudo-versions (e.g., `v0.0.0-20191109021931-daa7c04131f5`) are compared +/// by their base version. For example, `v1.2.4-0.20191109021931-xxx` is treated +/// as being based on `v1.2.3`. +/// +/// # Incompatible Suffix +/// +/// The `+incompatible` suffix is stripped before comparison. +/// +/// # Returns +/// +/// - `Ordering::Less` if v1 < v2 +/// - `Ordering::Equal` if v1 == v2 +/// - `Ordering::Greater` if v1 > v2 +/// +/// # Examples +/// +/// ``` +/// use deps_go::compare_versions; +/// use std::cmp::Ordering; +/// +/// assert_eq!(compare_versions("v1.0.0", "v2.0.0"), Ordering::Less); +/// assert_eq!(compare_versions("v2.0.0+incompatible", "v2.0.0"), Ordering::Equal); +/// ``` +pub fn compare_versions(v1: &str, v2: &str) -> Ordering { + let clean1 = v1.trim_start_matches('v').replace("+incompatible", ""); + let clean2 = v2.trim_start_matches('v').replace("+incompatible", ""); + + let cmp1 = if is_pseudo_version(v1) { + base_version_from_pseudo(v1).unwrap_or(clean1.clone()) + } else { + clean1.clone() + }; + + let cmp2 = if is_pseudo_version(v2) { + base_version_from_pseudo(v2).unwrap_or(clean2.clone()) + } else { + clean2.clone() + }; + + match (parse_semver(&cmp1), parse_semver(&cmp2)) { + (Ok(ver1), Ok(ver2)) => ver1.cmp(&ver2), + _ => v1.cmp(v2), + } +} + +fn parse_semver(version: &str) -> Result { + let cleaned = version.trim_start_matches('v'); + + let split_at_prerelease = cleaned.split('-').next().unwrap_or(cleaned); + + semver::Version::parse(split_at_prerelease).map_err(|e| GoError::InvalidVersionSpecifier { + specifier: version.to_string(), + message: e.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_module_path() { + assert_eq!( + escape_module_path("github.com/User/Repo"), + "github.com/!user/!repo" + ); + assert_eq!( + escape_module_path("github.com/gin-gonic/gin"), + "github.com/gin-gonic/gin" + ); + assert_eq!( + escape_module_path("github.com/user/repo"), + "github.com/user/repo" + ); + } + + #[test] + fn test_escape_module_path_multiple_uppercase() { + assert_eq!( + escape_module_path("github.com/MyUser/MyRepo"), + "github.com/!my!user/!my!repo" + ); + } + + #[test] + fn test_is_pseudo_version() { + assert!(is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5")); + assert!(is_pseudo_version("v1.2.4-0.20191109021931-daa7c04131f5")); + assert!(!is_pseudo_version("v1.2.3")); + assert!(!is_pseudo_version("v1.2.3-beta.1")); + } + + #[test] + fn test_is_pseudo_version_with_incompatible() { + assert!(is_pseudo_version( + "v2.0.1-0.20191109021931-daa7c04131f5+incompatible" + )); + } + + #[test] + fn test_base_version_from_pseudo() { + assert_eq!( + base_version_from_pseudo("v1.2.4-0.20191109021931-daa7c04131f5"), + Some("v1.2.3".to_string()) + ); + assert_eq!( + base_version_from_pseudo("v0.0.0-20191109021931-daa7c04131f5"), + Some("v0.0.0".to_string()) + ); + } + + #[test] + fn test_base_version_from_pseudo_invalid() { + assert_eq!(base_version_from_pseudo("v1.2.3"), None); + } + + #[test] + fn test_compare_versions() { + assert_eq!(compare_versions("v1.0.0", "v2.0.0"), Ordering::Less); + assert_eq!(compare_versions("v1.2.3", "v1.2.3"), Ordering::Equal); + assert_eq!(compare_versions("v2.0.0", "v1.0.0"), Ordering::Greater); + } + + #[test] + fn test_compare_versions_patch() { + assert_eq!(compare_versions("v1.2.3", "v1.2.4"), Ordering::Less); + assert_eq!(compare_versions("v1.2.5", "v1.2.4"), Ordering::Greater); + } + + #[test] + fn test_compare_versions_minor() { + assert_eq!(compare_versions("v1.2.0", "v1.3.0"), Ordering::Less); + assert_eq!(compare_versions("v1.5.0", "v1.3.0"), Ordering::Greater); + } + + #[test] + fn test_compare_versions_incompatible() { + assert_eq!( + compare_versions("v2.0.0+incompatible", "v2.1.0+incompatible"), + Ordering::Less + ); + } + + #[test] + fn test_parse_semver_valid() { + assert!(parse_semver("1.2.3").is_ok()); + assert!(parse_semver("v1.2.3").is_ok()); + } + + #[test] + fn test_parse_semver_invalid() { + assert!(parse_semver("invalid").is_err()); + assert!(parse_semver("v1.2").is_err()); + } + + #[test] + fn test_pseudo_regex_compiles() { + let _ = is_pseudo_version("v0.0.0-20191109021931-daa7c04131f5"); + } +} diff --git a/crates/deps-lsp/Cargo.toml b/crates/deps-lsp/Cargo.toml index 125e5e0c..518a3c6e 100644 --- a/crates/deps-lsp/Cargo.toml +++ b/crates/deps-lsp/Cargo.toml @@ -23,6 +23,7 @@ deps-core = { workspace = true } deps-cargo = { workspace = true } deps-npm = { workspace = true } deps-pypi = { workspace = true } +deps-go = { workspace = true } # External dependencies dashmap = { workspace = true } diff --git a/crates/deps-lsp/README.md b/crates/deps-lsp/README.md index 5d972173..baf30326 100644 --- a/crates/deps-lsp/README.md +++ b/crates/deps-lsp/README.md @@ -9,7 +9,7 @@ Language Server Protocol implementation for dependency management. ## Features -- **Multi-ecosystem** — Cargo.toml, package.json, pyproject.toml +- **Multi-ecosystem** — Cargo.toml, package.json, pyproject.toml, go.mod - **Inlay Hints** — Show latest versions inline - **Hover Info** — Package descriptions and version lists - **Code Actions** — Quick fixes to update dependencies diff --git a/crates/deps-lsp/src/document/state.rs b/crates/deps-lsp/src/document/state.rs index c5090ae3..adc4bacd 100644 --- a/crates/deps-lsp/src/document/state.rs +++ b/crates/deps-lsp/src/document/state.rs @@ -3,6 +3,7 @@ use deps_cargo::{CargoVersion, ParsedDependency}; use deps_core::HttpCache; use deps_core::lockfile::LockFileCache; use deps_core::{EcosystemRegistry, ParseResult}; +use deps_go::{GoDependency, GoVersion}; use deps_npm::{NpmDependency, NpmVersion}; use deps_pypi::{PypiDependency, PypiVersion}; use std::collections::HashMap; @@ -21,6 +22,7 @@ pub enum UnifiedDependency { Cargo(ParsedDependency), Npm(NpmDependency), Pypi(PypiDependency), + Go(GoDependency), } impl UnifiedDependency { @@ -30,6 +32,7 @@ impl UnifiedDependency { UnifiedDependency::Cargo(dep) => &dep.name, UnifiedDependency::Npm(dep) => &dep.name, UnifiedDependency::Pypi(dep) => &dep.name, + UnifiedDependency::Go(dep) => &dep.module_path, } } @@ -39,6 +42,7 @@ impl UnifiedDependency { UnifiedDependency::Cargo(dep) => dep.name_range, UnifiedDependency::Npm(dep) => dep.name_range, UnifiedDependency::Pypi(dep) => dep.name_range, + UnifiedDependency::Go(dep) => dep.module_path_range, } } @@ -48,6 +52,7 @@ impl UnifiedDependency { UnifiedDependency::Cargo(dep) => dep.version_req.as_deref(), UnifiedDependency::Npm(dep) => dep.version_req.as_deref(), UnifiedDependency::Pypi(dep) => dep.version_req.as_deref(), + UnifiedDependency::Go(dep) => dep.version.as_deref(), } } @@ -57,6 +62,7 @@ impl UnifiedDependency { UnifiedDependency::Cargo(dep) => dep.version_range, UnifiedDependency::Npm(dep) => dep.version_range, UnifiedDependency::Pypi(dep) => dep.version_range, + UnifiedDependency::Go(dep) => dep.version_range, } } @@ -70,6 +76,7 @@ impl UnifiedDependency { UnifiedDependency::Pypi(dep) => { matches!(dep.source, deps_pypi::PypiDependencySource::PyPI) } + UnifiedDependency::Go(_) => true, } } } @@ -83,6 +90,7 @@ pub enum UnifiedVersion { Cargo(CargoVersion), Npm(NpmVersion), Pypi(PypiVersion), + Go(GoVersion), } impl UnifiedVersion { @@ -92,6 +100,7 @@ impl UnifiedVersion { UnifiedVersion::Cargo(v) => &v.num, UnifiedVersion::Npm(v) => &v.version, UnifiedVersion::Pypi(v) => &v.version, + UnifiedVersion::Go(v) => &v.version, } } @@ -101,6 +110,7 @@ impl UnifiedVersion { UnifiedVersion::Cargo(v) => v.yanked, UnifiedVersion::Npm(v) => v.deprecated, UnifiedVersion::Pypi(v) => v.yanked, + UnifiedVersion::Go(v) => v.retracted, } } } @@ -150,6 +160,8 @@ pub enum Ecosystem { Npm, /// Python PyPI ecosystem (pyproject.toml) Pypi, + /// Go modules ecosystem (go.mod) + Go, } impl Ecosystem { @@ -162,6 +174,7 @@ impl Ecosystem { "Cargo.toml" => Some(Self::Cargo), "package.json" => Some(Self::Npm), "pyproject.toml" => Some(Self::Pypi), + "go.mod" => Some(Self::Go), _ => None, } } @@ -363,6 +376,7 @@ impl DocumentState { Ecosystem::Cargo => "cargo", Ecosystem::Npm => "npm", Ecosystem::Pypi => "pypi", + Ecosystem::Go => "go", }; Self { @@ -390,6 +404,7 @@ impl DocumentState { "cargo" => Ecosystem::Cargo, "npm" => Ecosystem::Npm, "pypi" => Ecosystem::Pypi, + "go" => Ecosystem::Go, _ => Ecosystem::Cargo, // Default fallback }; @@ -415,6 +430,7 @@ impl DocumentState { "cargo" => Ecosystem::Cargo, "npm" => Ecosystem::Npm, "pypi" => Ecosystem::Pypi, + "go" => Ecosystem::Go, _ => Ecosystem::Cargo, // Default fallback }; @@ -502,6 +518,10 @@ impl ServerState { let pypi_ecosystem = Arc::new(deps_pypi::PypiEcosystem::new(Arc::clone(&cache))); ecosystem_registry.register(pypi_ecosystem); + // Register Go ecosystem + let go_ecosystem = Arc::new(deps_go::GoEcosystem::new(Arc::clone(&cache))); + ecosystem_registry.register(go_ecosystem); + // Create cold start limiter with default 100ms interval (10 req/sec per URI) let cold_start_limiter = ColdStartLimiter::new(Duration::from_millis(100)); @@ -1168,4 +1188,241 @@ dependencies = ["requests>=2.0.0"] "All other requests should be blocked" ); } + + #[test] + fn test_ecosystem_from_filename_go() { + assert_eq!(Ecosystem::from_filename("go.mod"), Some(Ecosystem::Go)); + } + + #[test] + fn test_ecosystem_from_uri_go() { + let go_uri = Uri::from_file_path("/path/to/go.mod").unwrap(); + assert_eq!(Ecosystem::from_uri(&go_uri), Some(Ecosystem::Go)); + } + + #[test] + fn test_unified_dependency_go() { + use deps_go::{GoDependency, GoDirective}; + use tower_lsp_server::ls_types::{Position, Range}; + + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/gin-gonic/gin".into(), + module_path_range: Range::new(Position::new(0, 0), Position::new(0, 25)), + version: Some("v1.9.1".into()), + version_range: Some(Range::new(Position::new(0, 26), Position::new(0, 32))), + directive: GoDirective::Require, + indirect: false, + }); + + assert_eq!(go_dep.name(), "github.com/gin-gonic/gin"); + assert_eq!(go_dep.version_req(), Some("v1.9.1")); + assert!(go_dep.is_registry()); + } + + #[test] + fn test_unified_dependency_go_name_range() { + use deps_go::{GoDependency, GoDirective}; + use tower_lsp_server::ls_types::{Position, Range}; + + let range = Range::new(Position::new(5, 10), Position::new(5, 35)); + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/example/pkg".into(), + module_path_range: range, + version: Some("v1.0.0".into()), + version_range: Some(Range::new(Position::new(5, 36), Position::new(5, 42))), + directive: GoDirective::Require, + indirect: false, + }); + + assert_eq!(go_dep.name_range(), range); + } + + #[test] + fn test_unified_dependency_go_version_range() { + use deps_go::{GoDependency, GoDirective}; + use tower_lsp_server::ls_types::{Position, Range}; + + let version_range = Range::new(Position::new(5, 36), Position::new(5, 42)); + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/example/pkg".into(), + module_path_range: Range::new(Position::new(5, 10), Position::new(5, 35)), + version: Some("v1.0.0".into()), + version_range: Some(version_range), + directive: GoDirective::Require, + indirect: false, + }); + + assert_eq!(go_dep.version_range(), Some(version_range)); + } + + #[test] + fn test_unified_dependency_go_no_version() { + use deps_go::{GoDependency, GoDirective}; + use tower_lsp_server::ls_types::{Position, Range}; + + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/example/pkg".into(), + module_path_range: Range::new(Position::new(5, 10), Position::new(5, 35)), + version: None, + version_range: None, + directive: GoDirective::Require, + indirect: false, + }); + + assert_eq!(go_dep.version_req(), None); + assert_eq!(go_dep.version_range(), None); + } + + #[test] + fn test_unified_version_go() { + use deps_go::GoVersion; + + let version = UnifiedVersion::Go(GoVersion { + version: "v1.9.1".into(), + time: Some("2023-07-18T14:30:00Z".into()), + is_pseudo: false, + retracted: false, + }); + + assert_eq!(version.version_string(), "v1.9.1"); + assert!(!version.is_yanked()); + } + + #[test] + fn test_unified_version_go_retracted() { + use deps_go::GoVersion; + + let version = UnifiedVersion::Go(GoVersion { + version: "v1.0.0".into(), + time: None, + is_pseudo: false, + retracted: true, + }); + + assert_eq!(version.version_string(), "v1.0.0"); + assert!(version.is_yanked()); + } + + #[test] + fn test_unified_version_go_pseudo() { + use deps_go::GoVersion; + + let version = UnifiedVersion::Go(GoVersion { + version: "v0.0.0-20191109021931-daa7c04131f5".into(), + time: Some("2019-11-09T02:19:31Z".into()), + is_pseudo: true, + retracted: false, + }); + + assert_eq!( + version.version_string(), + "v0.0.0-20191109021931-daa7c04131f5" + ); + assert!(!version.is_yanked()); + } + + #[test] + fn test_document_state_new_go() { + use deps_go::{GoDependency, GoDirective}; + use tower_lsp_server::ls_types::{Position, Range}; + + let deps = vec![UnifiedDependency::Go(GoDependency { + module_path: "github.com/gin-gonic/gin".into(), + module_path_range: Range::new(Position::new(0, 0), Position::new(0, 25)), + version: Some("v1.9.1".into()), + version_range: Some(Range::new(Position::new(0, 26), Position::new(0, 32))), + directive: GoDirective::Require, + indirect: false, + })]; + + let state = DocumentState::new(Ecosystem::Go, "test content".into(), deps); + + assert_eq!(state.ecosystem, Ecosystem::Go); + assert_eq!(state.ecosystem_id, "go"); + assert_eq!(state.content, "test content"); + assert_eq!(state.dependencies.len(), 1); + assert!(state.versions.is_empty()); + } + + #[test] + fn test_document_state_new_without_parse_result_go() { + let content = r#"module example.com/myapp + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +"# + .to_string(); + + let doc_state = DocumentState::new_without_parse_result("go", content.clone()); + + assert_eq!(doc_state.ecosystem_id, "go"); + assert_eq!(doc_state.ecosystem, Ecosystem::Go); + assert_eq!(doc_state.content, content); + assert!(doc_state.parse_result.is_none()); + assert!(doc_state.dependencies.is_empty()); + } + + #[test] + fn test_document_state_new_from_parse_result_go() { + let state = ServerState::new(); + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let ecosystem = state.ecosystem_registry.get("go").unwrap(); + + let content = r#"module example.com/myapp + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +"# + .to_string(); + + let parse_result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(ecosystem.parse_manifest(&content, &uri)) + .unwrap(); + + let doc_state = DocumentState::new_from_parse_result("go", content.clone(), parse_result); + + assert_eq!(doc_state.ecosystem_id, "go"); + assert_eq!(doc_state.ecosystem, Ecosystem::Go); + assert_eq!(doc_state.content, content); + assert!(doc_state.parse_result.is_some()); + } + + #[test] + fn test_unified_version_go_version_string_getter() { + use deps_go::GoVersion; + + let version = UnifiedVersion::Go(GoVersion { + version: "v1.9.1".into(), + time: None, + is_pseudo: false, + retracted: false, + }); + + assert_eq!(version.version_string(), "v1.9.1"); + } + + #[test] + fn test_unified_version_go_yanked_checker() { + use deps_go::GoVersion; + + let retracted = UnifiedVersion::Go(GoVersion { + version: "v1.0.0".into(), + time: None, + is_pseudo: false, + retracted: true, + }); + + let normal = UnifiedVersion::Go(GoVersion { + version: "v1.9.1".into(), + time: None, + is_pseudo: false, + retracted: false, + }); + + assert!(retracted.is_yanked()); + assert!(!normal.is_yanked()); + } }