Skip to main content

deps_go/
ecosystem.rs

1//! Go modules ecosystem implementation for deps-lsp.
2//!
3//! This module implements the `Ecosystem` trait for Go projects,
4//! providing LSP functionality for `go.mod` files.
5
6use async_trait::async_trait;
7use std::any::Any;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tower_lsp_server::ls_types::{
11    CodeAction, CompletionItem, Diagnostic, Hover, InlayHint, Position, Uri,
12};
13
14use deps_core::{
15    Ecosystem, EcosystemConfig, ParseResult as ParseResultTrait, Registry, Result, lsp_helpers,
16};
17
18use crate::formatter::GoFormatter;
19use crate::registry::GoRegistry;
20
21/// Go modules ecosystem implementation.
22///
23/// Provides LSP functionality for go.mod files, including:
24/// - Dependency parsing with position tracking
25/// - Version information from proxy.golang.org
26/// - Inlay hints for latest versions
27/// - Hover tooltips with package metadata
28/// - Code actions for version updates
29/// - Diagnostics for unknown packages
30pub struct GoEcosystem {
31    registry: Arc<GoRegistry>,
32    formatter: GoFormatter,
33}
34
35impl GoEcosystem {
36    /// Creates a new Go ecosystem with the given HTTP cache.
37    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
38        Self {
39            registry: Arc::new(GoRegistry::new(cache)),
40            formatter: GoFormatter,
41        }
42    }
43
44    /// Completes package names.
45    ///
46    /// Go doesn't have a centralized search API like crates.io or npm.
47    /// Users typically know the full module path (e.g., github.com/gin-gonic/gin).
48    /// This implementation returns empty results for now.
49    ///
50    /// Future enhancements could include:
51    /// - Popular packages database
52    /// - Local workspace module paths
53    /// - Integration with go.sum for recently used modules
54    async fn complete_package_names(&self, _prefix: &str) -> Vec<CompletionItem> {
55        // Go modules don't have a centralized search API
56        // Users typically know the full module path
57        vec![]
58    }
59
60    async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
61        deps_core::completion::complete_versions_generic(
62            self.registry.as_ref(),
63            package_name,
64            prefix,
65            &[],
66        )
67        .await
68    }
69
70    /// Completes feature flags for a specific package.
71    ///
72    /// Go modules don't have a feature flag system like Cargo.
73    /// Returns empty results.
74    async fn complete_features(&self, _package_name: &str, _prefix: &str) -> Vec<CompletionItem> {
75        // Go modules don't have feature flags
76        vec![]
77    }
78}
79
80#[async_trait]
81impl Ecosystem for GoEcosystem {
82    fn id(&self) -> &'static str {
83        "go"
84    }
85
86    fn display_name(&self) -> &'static str {
87        "Go Modules"
88    }
89
90    fn manifest_filenames(&self) -> &[&'static str] {
91        &["go.mod"]
92    }
93
94    fn lockfile_filenames(&self) -> &[&'static str] {
95        &["go.sum"]
96    }
97
98    async fn parse_manifest(&self, content: &str, uri: &Uri) -> Result<Box<dyn ParseResultTrait>> {
99        let result = crate::parser::parse_go_mod(content, uri)?;
100        Ok(Box::new(result))
101    }
102
103    fn registry(&self) -> Arc<dyn Registry> {
104        self.registry.clone() as Arc<dyn Registry>
105    }
106
107    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
108        Some(Arc::new(crate::lockfile::GoSumParser))
109    }
110
111    async fn generate_inlay_hints(
112        &self,
113        parse_result: &dyn ParseResultTrait,
114        cached_versions: &HashMap<String, String>,
115        resolved_versions: &HashMap<String, String>,
116        loading_state: deps_core::LoadingState,
117        config: &EcosystemConfig,
118    ) -> Vec<InlayHint> {
119        lsp_helpers::generate_inlay_hints(
120            parse_result,
121            cached_versions,
122            resolved_versions,
123            loading_state,
124            config,
125            &self.formatter,
126        )
127    }
128
129    async fn generate_hover(
130        &self,
131        parse_result: &dyn ParseResultTrait,
132        position: Position,
133        cached_versions: &HashMap<String, String>,
134        resolved_versions: &HashMap<String, String>,
135    ) -> Option<Hover> {
136        lsp_helpers::generate_hover(
137            parse_result,
138            position,
139            cached_versions,
140            resolved_versions,
141            self.registry.as_ref(),
142            &self.formatter,
143        )
144        .await
145    }
146
147    async fn generate_code_actions(
148        &self,
149        parse_result: &dyn ParseResultTrait,
150        position: Position,
151        _cached_versions: &HashMap<String, String>,
152        uri: &Uri,
153    ) -> Vec<CodeAction> {
154        lsp_helpers::generate_code_actions(
155            parse_result,
156            position,
157            uri,
158            self.registry.as_ref(),
159            &self.formatter,
160        )
161        .await
162    }
163
164    async fn generate_diagnostics(
165        &self,
166        parse_result: &dyn ParseResultTrait,
167        cached_versions: &HashMap<String, String>,
168        resolved_versions: &HashMap<String, String>,
169        _uri: &Uri,
170    ) -> Vec<Diagnostic> {
171        lsp_helpers::generate_diagnostics_from_cache(
172            parse_result,
173            cached_versions,
174            resolved_versions,
175            &self.formatter,
176        )
177    }
178
179    async fn generate_completions(
180        &self,
181        parse_result: &dyn ParseResultTrait,
182        position: Position,
183        content: &str,
184    ) -> Vec<CompletionItem> {
185        use deps_core::completion::{CompletionContext, detect_completion_context};
186
187        let context = detect_completion_context(parse_result, position, content);
188
189        match context {
190            CompletionContext::PackageName { prefix } => self.complete_package_names(&prefix).await,
191            CompletionContext::Version {
192                package_name,
193                prefix,
194            } => self.complete_versions(&package_name, &prefix).await,
195            CompletionContext::Feature {
196                package_name,
197                prefix,
198            } => self.complete_features(&package_name, &prefix).await,
199            CompletionContext::None => vec![],
200        }
201    }
202
203    fn as_any(&self) -> &dyn Any {
204        self
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::types::{GoDependency, GoDirective};
212    use deps_core::Dependency;
213    use std::collections::HashMap;
214    use tower_lsp_server::ls_types::{InlayHintLabel, Position, Range};
215
216    /// Mock dependency for testing
217    fn mock_dependency(name: &str, version: Option<&str>, line: u32) -> GoDependency {
218        GoDependency {
219            module_path: name.to_string(),
220            module_path_range: Range::new(
221                Position::new(line, 0),
222                Position::new(line, name.len() as u32),
223            ),
224            version: version.map(String::from),
225            version_range: version
226                .map(|_| Range::new(Position::new(line, 0), Position::new(line, 10))),
227            directive: GoDirective::Require,
228            indirect: false,
229        }
230    }
231
232    /// Mock parse result for testing
233    struct MockParseResult {
234        dependencies: Vec<GoDependency>,
235        uri: Uri,
236    }
237
238    impl deps_core::ParseResult for MockParseResult {
239        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
240            self.dependencies
241                .iter()
242                .map(|d| d as &dyn deps_core::Dependency)
243                .collect()
244        }
245
246        fn workspace_root(&self) -> Option<&std::path::Path> {
247            None
248        }
249
250        fn uri(&self) -> &Uri {
251            &self.uri
252        }
253
254        fn as_any(&self) -> &dyn Any {
255            self
256        }
257    }
258
259    #[test]
260    fn test_ecosystem_id() {
261        let cache = Arc::new(deps_core::HttpCache::new());
262        let ecosystem = GoEcosystem::new(cache);
263        assert_eq!(ecosystem.id(), "go");
264    }
265
266    #[test]
267    fn test_ecosystem_display_name() {
268        let cache = Arc::new(deps_core::HttpCache::new());
269        let ecosystem = GoEcosystem::new(cache);
270        assert_eq!(ecosystem.display_name(), "Go Modules");
271    }
272
273    #[test]
274    fn test_ecosystem_manifest_filenames() {
275        let cache = Arc::new(deps_core::HttpCache::new());
276        let ecosystem = GoEcosystem::new(cache);
277        assert_eq!(ecosystem.manifest_filenames(), &["go.mod"]);
278    }
279
280    #[test]
281    fn test_ecosystem_lockfile_filenames() {
282        let cache = Arc::new(deps_core::HttpCache::new());
283        let ecosystem = GoEcosystem::new(cache);
284        assert_eq!(ecosystem.lockfile_filenames(), &["go.sum"]);
285    }
286
287    #[test]
288    fn test_generate_inlay_hints_up_to_date() {
289        let cache = Arc::new(deps_core::HttpCache::new());
290        let ecosystem = GoEcosystem::new(cache);
291
292        let uri = Uri::from_file_path("/test/go.mod").unwrap();
293        let parse_result = MockParseResult {
294            dependencies: vec![mock_dependency(
295                "github.com/gin-gonic/gin",
296                Some("v1.9.1"),
297                5,
298            )],
299            uri,
300        };
301
302        let mut cached_versions = HashMap::new();
303        cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string());
304
305        let config = EcosystemConfig {
306            loading_text: "⏳".to_string(),
307            show_loading_hints: true,
308            show_up_to_date_hints: true,
309            up_to_date_text: "✅".to_string(),
310            needs_update_text: "❌ {}".to_string(),
311        };
312
313        // Lock file has the latest version
314        let mut resolved_versions = HashMap::new();
315        resolved_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string());
316        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
317            &parse_result,
318            &cached_versions,
319            &resolved_versions,
320            deps_core::LoadingState::Loaded,
321            &config,
322        ));
323
324        assert_eq!(hints.len(), 1);
325        match &hints[0].label {
326            InlayHintLabel::String(s) => assert_eq!(s, "✅ v1.9.1"),
327            _ => panic!("Expected String label"),
328        }
329    }
330
331    #[test]
332    fn test_generate_inlay_hints_needs_update() {
333        let cache = Arc::new(deps_core::HttpCache::new());
334        let ecosystem = GoEcosystem::new(cache);
335
336        let uri = Uri::from_file_path("/test/go.mod").unwrap();
337        let parse_result = MockParseResult {
338            dependencies: vec![mock_dependency(
339                "github.com/gin-gonic/gin",
340                Some("v1.9.0"),
341                5,
342            )],
343            uri,
344        };
345
346        let mut cached_versions = HashMap::new();
347        cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string());
348
349        let config = EcosystemConfig {
350            loading_text: "⏳".to_string(),
351            show_loading_hints: true,
352            show_up_to_date_hints: true,
353            up_to_date_text: "✅".to_string(),
354            needs_update_text: "❌ {}".to_string(),
355        };
356
357        let resolved_versions = HashMap::new();
358        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
359            &parse_result,
360            &cached_versions,
361            &resolved_versions,
362            deps_core::LoadingState::Loaded,
363            &config,
364        ));
365
366        assert_eq!(hints.len(), 1);
367        match &hints[0].label {
368            InlayHintLabel::String(s) => assert_eq!(s, "❌ v1.9.1"),
369            _ => panic!("Expected String label"),
370        }
371    }
372
373    #[test]
374    fn test_generate_inlay_hints_hide_up_to_date() {
375        let cache = Arc::new(deps_core::HttpCache::new());
376        let ecosystem = GoEcosystem::new(cache);
377
378        let uri = Uri::from_file_path("/test/go.mod").unwrap();
379        let parse_result = MockParseResult {
380            dependencies: vec![mock_dependency(
381                "github.com/gin-gonic/gin",
382                Some("v1.9.1"),
383                5,
384            )],
385            uri,
386        };
387
388        let mut cached_versions = HashMap::new();
389        cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string());
390
391        let config = EcosystemConfig {
392            loading_text: "⏳".to_string(),
393            show_loading_hints: true,
394            show_up_to_date_hints: false,
395            up_to_date_text: "✅".to_string(),
396            needs_update_text: "❌ {}".to_string(),
397        };
398
399        // Lock file has the latest version - but show_up_to_date_hints is false
400        let mut resolved_versions = HashMap::new();
401        resolved_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string());
402        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
403            &parse_result,
404            &cached_versions,
405            &resolved_versions,
406            deps_core::LoadingState::Loaded,
407            &config,
408        ));
409
410        assert_eq!(hints.len(), 0);
411    }
412
413    #[test]
414    fn test_generate_inlay_hints_no_version_range() {
415        let cache = Arc::new(deps_core::HttpCache::new());
416        let ecosystem = GoEcosystem::new(cache);
417
418        let mut dep = mock_dependency("github.com/gin-gonic/gin", Some("v1.9.1"), 5);
419        dep.version_range = None;
420
421        let uri = Uri::from_file_path("/test/go.mod").unwrap();
422        let parse_result = MockParseResult {
423            dependencies: vec![dep],
424            uri,
425        };
426
427        let mut cached_versions = HashMap::new();
428        cached_versions.insert("github.com/gin-gonic/gin".to_string(), "v1.9.1".to_string());
429
430        let config = EcosystemConfig {
431            loading_text: "⏳".to_string(),
432            show_loading_hints: true,
433            show_up_to_date_hints: true,
434            up_to_date_text: "✅".to_string(),
435            needs_update_text: "❌ {}".to_string(),
436        };
437
438        let resolved_versions = HashMap::new();
439        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
440            &parse_result,
441            &cached_versions,
442            &resolved_versions,
443            deps_core::LoadingState::Loaded,
444            &config,
445        ));
446
447        assert_eq!(hints.len(), 0);
448    }
449
450    #[test]
451    fn test_as_any() {
452        let cache = Arc::new(deps_core::HttpCache::new());
453        let ecosystem = GoEcosystem::new(cache);
454
455        // Verify we can downcast
456        let any = ecosystem.as_any();
457        assert!(any.is::<GoEcosystem>());
458    }
459
460    #[tokio::test]
461    async fn test_complete_package_names_empty() {
462        let cache = Arc::new(deps_core::HttpCache::new());
463        let ecosystem = GoEcosystem::new(cache);
464
465        // Go doesn't have package search, should always return empty
466        let results = ecosystem.complete_package_names("github").await;
467        assert!(results.is_empty());
468    }
469
470    #[tokio::test]
471    #[ignore] // Requires network access
472    async fn test_complete_versions_real() {
473        let cache = Arc::new(deps_core::HttpCache::new());
474        let ecosystem = GoEcosystem::new(cache);
475
476        let results = ecosystem
477            .complete_versions("github.com/gin-gonic/gin", "v1.9")
478            .await;
479        assert!(!results.is_empty());
480        assert!(results.iter().all(|r| r.label.starts_with("v1.9")));
481    }
482
483    #[tokio::test]
484    async fn test_complete_versions_unknown_package() {
485        let cache = Arc::new(deps_core::HttpCache::new());
486        let ecosystem = GoEcosystem::new(cache);
487
488        // Unknown package should return empty (graceful degradation)
489        let results = ecosystem
490            .complete_versions("github.com/nonexistent/package12345", "v1.0")
491            .await;
492        assert!(results.is_empty());
493    }
494
495    #[tokio::test]
496    async fn test_complete_features_always_empty() {
497        let cache = Arc::new(deps_core::HttpCache::new());
498        let ecosystem = GoEcosystem::new(cache);
499
500        // Go doesn't have features, should always return empty
501        let results = ecosystem
502            .complete_features("github.com/gin-gonic/gin", "")
503            .await;
504        assert!(results.is_empty());
505    }
506
507    #[tokio::test]
508    #[ignore] // Requires network access
509    async fn test_complete_versions_limit_20() {
510        let cache = Arc::new(deps_core::HttpCache::new());
511        let ecosystem = GoEcosystem::new(cache);
512
513        // Test that we respect the 20 result limit
514        let results = ecosystem
515            .complete_versions("github.com/gin-gonic/gin", "v")
516            .await;
517        assert!(results.len() <= 20);
518    }
519
520    #[tokio::test]
521    async fn test_generate_hover_on_module_path() {
522        let cache = Arc::new(deps_core::HttpCache::new());
523        let ecosystem = GoEcosystem::new(cache);
524
525        let uri = Uri::from_file_path("/test/go.mod").unwrap();
526        let parse_result = MockParseResult {
527            dependencies: vec![mock_dependency(
528                "github.com/gin-gonic/gin",
529                Some("v1.9.1"),
530                5,
531            )],
532            uri,
533        };
534
535        let position = Position::new(5, 5);
536        let cached_versions = HashMap::new();
537        let resolved_versions = HashMap::new();
538
539        let hover = ecosystem
540            .generate_hover(
541                &parse_result,
542                position,
543                &cached_versions,
544                &resolved_versions,
545            )
546            .await;
547
548        // Returns hover with package URL
549        assert!(hover.is_some());
550        let hover_content = hover.unwrap();
551        let markdown = format!("{:?}", hover_content.contents);
552        assert!(markdown.contains("pkg.go.dev"));
553    }
554
555    #[tokio::test]
556    async fn test_generate_hover_outside_dependency() {
557        let cache = Arc::new(deps_core::HttpCache::new());
558        let ecosystem = GoEcosystem::new(cache);
559
560        let uri = Uri::from_file_path("/test/go.mod").unwrap();
561        let parse_result = MockParseResult {
562            dependencies: vec![mock_dependency(
563                "github.com/gin-gonic/gin",
564                Some("v1.9.1"),
565                5,
566            )],
567            uri,
568        };
569
570        let position = Position::new(0, 0);
571        let cached_versions = HashMap::new();
572        let resolved_versions = HashMap::new();
573
574        let hover = ecosystem
575            .generate_hover(
576                &parse_result,
577                position,
578                &cached_versions,
579                &resolved_versions,
580            )
581            .await;
582
583        assert!(hover.is_none());
584    }
585
586    #[tokio::test]
587    async fn test_generate_code_actions_on_module() {
588        let cache = Arc::new(deps_core::HttpCache::new());
589        let ecosystem = GoEcosystem::new(cache);
590
591        let uri = Uri::from_file_path("/test/go.mod").unwrap();
592        let parse_result = MockParseResult {
593            dependencies: vec![mock_dependency(
594                "github.com/gin-gonic/gin",
595                Some("v1.9.0"),
596                5,
597            )],
598            uri: uri.clone(),
599        };
600
601        let position = Position::new(5, 5);
602        let cached_versions = HashMap::new();
603
604        let actions = ecosystem
605            .generate_code_actions(&parse_result, position, &cached_versions, &uri)
606            .await;
607
608        // Returns actions (open documentation link)
609        assert!(!actions.is_empty());
610    }
611
612    #[tokio::test]
613    #[ignore = "Requires network access to proxy.golang.org"]
614    async fn test_generate_diagnostics_basic() {
615        let cache = Arc::new(deps_core::HttpCache::new());
616        let ecosystem = GoEcosystem::new(cache);
617
618        let uri = Uri::from_file_path("/test/go.mod").unwrap();
619        let parse_result = MockParseResult {
620            dependencies: vec![mock_dependency(
621                "github.com/gin-gonic/gin",
622                Some("v1.9.1"),
623                5,
624            )],
625            uri,
626        };
627
628        let cached_versions = HashMap::new();
629        let resolved_versions = HashMap::new();
630
631        // Use timeout to prevent hanging
632        let result = tokio::time::timeout(
633            std::time::Duration::from_secs(5),
634            ecosystem.generate_diagnostics(
635                &parse_result,
636                &cached_versions,
637                &resolved_versions,
638                parse_result.uri(),
639            ),
640        )
641        .await;
642
643        // Should complete within timeout
644        assert!(result.is_ok(), "Diagnostic generation timed out");
645    }
646
647    #[tokio::test]
648    async fn test_generate_completions_package_name() {
649        let cache = Arc::new(deps_core::HttpCache::new());
650        let ecosystem = GoEcosystem::new(cache);
651
652        let content = r"module example.com/myapp
653
654go 1.21
655
656require github.com/
657";
658
659        let uri = Uri::from_file_path("/test/go.mod").unwrap();
660        let parse_result = MockParseResult {
661            dependencies: vec![],
662            uri,
663        };
664
665        let position = Position::new(4, 19);
666
667        let completions = ecosystem
668            .generate_completions(&parse_result, position, content)
669            .await;
670
671        // Go doesn't support package search, should be empty
672        assert!(completions.is_empty());
673    }
674
675    #[tokio::test]
676    async fn test_generate_completions_outside_context() {
677        let cache = Arc::new(deps_core::HttpCache::new());
678        let ecosystem = GoEcosystem::new(cache);
679
680        let content = r"module example.com/myapp
681
682go 1.21
683";
684
685        let uri = Uri::from_file_path("/test/go.mod").unwrap();
686        let parse_result = MockParseResult {
687            dependencies: vec![],
688            uri,
689        };
690
691        let position = Position::new(0, 0);
692
693        let completions = ecosystem
694            .generate_completions(&parse_result, position, content)
695            .await;
696
697        assert!(completions.is_empty());
698    }
699
700    #[tokio::test]
701    async fn test_parse_manifest_valid() {
702        let cache = Arc::new(deps_core::HttpCache::new());
703        let ecosystem = GoEcosystem::new(cache);
704
705        let content = r"module example.com/myapp
706
707go 1.21
708
709require github.com/gin-gonic/gin v1.9.1
710";
711
712        let uri = Uri::from_file_path("/test/go.mod").unwrap();
713
714        let result = ecosystem.parse_manifest(content, &uri).await;
715        assert!(result.is_ok());
716
717        let parse_result = result.unwrap();
718        assert_eq!(parse_result.dependencies().len(), 1);
719        assert_eq!(
720            parse_result.dependencies()[0].name(),
721            "github.com/gin-gonic/gin"
722        );
723    }
724
725    #[tokio::test]
726    async fn test_parse_manifest_empty() {
727        let cache = Arc::new(deps_core::HttpCache::new());
728        let ecosystem = GoEcosystem::new(cache);
729
730        let content = "";
731        let uri = Uri::from_file_path("/test/go.mod").unwrap();
732
733        let result = ecosystem.parse_manifest(content, &uri).await;
734        assert!(result.is_ok());
735
736        let parse_result = result.unwrap();
737        assert_eq!(parse_result.dependencies().len(), 0);
738    }
739
740    #[test]
741    fn test_registry_returns_trait_object() {
742        let cache = Arc::new(deps_core::HttpCache::new());
743        let ecosystem = GoEcosystem::new(cache);
744
745        let registry = ecosystem.registry();
746        assert_eq!(
747            registry.package_url("github.com/gin-gonic/gin"),
748            "https://pkg.go.dev/github.com/gin-gonic/gin"
749        );
750    }
751
752    #[test]
753    fn test_lockfile_provider_exists() {
754        let cache = Arc::new(deps_core::HttpCache::new());
755        let ecosystem = GoEcosystem::new(cache);
756
757        assert!(ecosystem.lockfile_provider().is_some());
758    }
759
760    #[test]
761    fn test_mock_dependency_indirect() {
762        let mut dep = mock_dependency("github.com/example/pkg", Some("v1.0.0"), 10);
763        dep.indirect = true;
764
765        assert!(dep.indirect);
766        assert_eq!(dep.name(), "github.com/example/pkg");
767    }
768}