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 std::any::Any;
7use std::future::Future;
8use std::sync::Arc;
9use tower_lsp_server::ls_types::{CompletionItem, Position, Uri};
10
11use deps_core::{
12    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
13    lsp_helpers::EcosystemFormatter,
14};
15
16use crate::config::GoParseContext;
17use crate::formatter::GoFormatter;
18use crate::registry::GoRegistry;
19
20/// Go modules ecosystem implementation.
21///
22/// Provides LSP functionality for go.mod files, including:
23/// - Dependency parsing with position tracking
24/// - Version information from proxy.golang.org
25/// - Inlay hints for latest versions
26/// - Hover tooltips with package metadata
27/// - Code actions for version updates
28/// - Diagnostics for unknown packages
29pub struct GoEcosystem {
30    registry: Arc<GoRegistry>,
31    formatter: GoFormatter,
32    /// The reachability policy and `$GOENV` memoization cache (spec 034) every
33    /// `parse_manifest` call threads through to [`crate::parser::parse_go_mod_with_context`].
34    /// Defaulted by [`Self::new`]; set explicitly by [`Self::with_context`] so
35    /// `crate::lib::register_ecosystems` can share one process-wide policy handle with
36    /// `ServerState` (mirrors `deps_npm::ecosystem::NpmEcosystem`'s identical `context`
37    /// field).
38    context: GoParseContext,
39}
40
41impl GoEcosystem {
42    /// Creates a new Go ecosystem with the given HTTP cache, using a fresh, default
43    /// [`GoParseContext`] — an all-`PublicOnly`-policy, empty-cache context private to this
44    /// ecosystem instance. Production use goes through [`Self::with_context`] instead.
45    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
46        Self::with_context(Arc::new(GoRegistry::new(cache)), GoParseContext::default())
47    }
48
49    /// Creates a new Go ecosystem sharing `ctx`'s reachability policy and `$GOENV`
50    /// memoization cache, around an existing [`GoRegistry`] instance — the production
51    /// constructor, used by `crate::lib::register_ecosystems` so `initialize`/
52    /// `workspace/didChangeConfiguration` can update the same `Arc<RegistryAccessPolicy>`
53    /// every parse reads.
54    #[must_use]
55    pub fn with_context(registry: Arc<GoRegistry>, ctx: GoParseContext) -> Self {
56        Self {
57            registry,
58            formatter: GoFormatter,
59            context: ctx,
60        }
61    }
62
63    /// Completes package names.
64    ///
65    /// Go doesn't have a centralized search API like crates.io or npm.
66    /// Users typically know the full module path (e.g., github.com/gin-gonic/gin).
67    /// This implementation returns empty results for now.
68    ///
69    /// Future enhancements could include:
70    /// - Popular packages database
71    /// - Local workspace module paths
72    /// - Integration with go.sum for recently used modules
73    fn complete_package_names(&self, _prefix: &str) -> impl Future<Output = Vec<CompletionItem>> {
74        // Go modules don't have a centralized search API
75        // Users typically know the full module path
76        std::future::ready(vec![])
77    }
78
79    /// Completes version requirements for the dependency at `position`, resolved by cursor
80    /// position rather than by name (issue #593) — delegates to
81    /// [`deps_core::completion::complete_versions_at_position`], which mirrors
82    /// `deps_gitlab_ci::ecosystem::GitLabCiEcosystem::generate_completions`'s reference
83    /// pattern. Position-based lookup also fixes a residual gap in the old name-based
84    /// routing (spec 034 F1): two dependencies sharing one `PackageName` but resolving to
85    /// different sources used to collapse into an ambiguous, empty result for both
86    /// occurrences, even though the cursor position unambiguously identifies which one the
87    /// user is editing.
88    ///
89    /// An unresolvable source still offers no completions rather than risking a private
90    /// module path lookup against `proxy.golang.org` — the shared helper's gate is what keeps
91    /// `Registry::get_versions_from`'s permissive routing of an unrecognized source to the
92    /// default public client (matching hover/diagnostics/code-actions' identical gate) from
93    /// leaking one for completions too.
94    async fn complete_versions(
95        &self,
96        parse_result: &dyn ParseResultTrait,
97        position: Position,
98        prefix: &str,
99        freshness: deps_core::FreshnessSettings,
100    ) -> Vec<CompletionItem> {
101        deps_core::completion::complete_versions_at_position(
102            self.registry.as_ref(),
103            &self.formatter,
104            parse_result,
105            position,
106            prefix,
107            &[],
108            freshness,
109        )
110        .await
111    }
112
113    /// Completes feature flags for a specific package.
114    ///
115    /// Go modules don't have a feature flag system like Cargo.
116    /// Returns empty results.
117    fn complete_features(
118        &self,
119        _package_name: &deps_core::PackageName,
120        _prefix: &str,
121    ) -> impl Future<Output = Vec<CompletionItem>> {
122        // Go modules don't have feature flags
123        std::future::ready(vec![])
124    }
125}
126
127impl deps_core::ecosystem::private::Sealed for GoEcosystem {}
128
129impl Ecosystem for GoEcosystem {
130    fn id(&self) -> &'static str {
131        "go"
132    }
133
134    fn display_name(&self) -> &'static str {
135        "Go Modules"
136    }
137
138    fn manifest_filenames(&self) -> &[&'static str] {
139        &["go.mod"]
140    }
141
142    fn lockfile_filenames(&self) -> &[&'static str] {
143        &["go.sum"]
144    }
145
146    fn parse_manifest<'a>(
147        &'a self,
148        content: &'a str,
149        uri: &'a Uri,
150    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
151        Box::pin(async move {
152            let result = crate::parser::parse_go_mod_with_context(content, uri, &self.context)?;
153            // Registers every `$GOENV`-resolved `GOPROXY`/`GOPRIVATE`-bypass chain this parse
154            // found (spec 034) into the shared router — the only point where a per-document
155            // `$GOENV` resolution and the long-lived `GoRegistry` this ecosystem shares across
156            // every document ever meet. See `GoParseResult::resolved_chains`.
157            for chain in &result.resolved_chains {
158                GoRegistry::register_chain(&self.registry, chain);
159            }
160            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
161        })
162    }
163
164    fn registry(&self) -> Arc<dyn Registry> {
165        self.registry.clone() as Arc<dyn Registry>
166    }
167
168    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
169        Some(Arc::new(crate::lockfile::GoSumParser))
170    }
171
172    fn formatter(&self) -> &dyn EcosystemFormatter {
173        &self.formatter
174    }
175
176    fn generate_completions<'a>(
177        &'a self,
178        parse_result: &'a dyn ParseResultTrait,
179        position: Position,
180        content: &'a str,
181        freshness: deps_core::FreshnessSettings,
182    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
183        Box::pin(async move {
184            use deps_core::completion::{CompletionContext, detect_completion_context};
185
186            let context = detect_completion_context(parse_result, position, content);
187
188            match context {
189                CompletionContext::PackageName { prefix, .. } => {
190                    self.complete_package_names(&prefix).await
191                }
192                CompletionContext::Version { prefix, .. } => {
193                    self.complete_versions(parse_result, position, &prefix, freshness)
194                        .await
195                }
196                CompletionContext::Feature {
197                    package_name,
198                    prefix,
199                } => self.complete_features(&package_name, &prefix).await,
200                CompletionContext::None => vec![],
201            }
202            .into()
203        })
204    }
205
206    fn as_any(&self) -> &dyn Any {
207        self
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::types::{GoDependency, GoDirective};
215    use deps_core::{
216        Dependency, EcosystemConfig, PackageVersions, VersionData, parser::DependencySource,
217    };
218    use std::collections::HashMap;
219    use tower_lsp_server::ls_types::{InlayHintLabel, Position, Range};
220
221    fn pkg(s: &str) -> deps_core::PackageName {
222        deps_core::PackageName::new(s)
223    }
224
225    /// Mock dependency for testing
226    fn mock_dependency(name: &str, version: Option<&str>, line: u32) -> GoDependency {
227        GoDependency {
228            module_path: name.into(),
229            module_path_range: Range::new(
230                Position::new(line, 0),
231                Position::new(line, name.len() as u32),
232            ),
233            version: version.map(Into::into),
234            version_range: version
235                .map(|_| Range::new(Position::new(line, 0), Position::new(line, 10))),
236            directive: GoDirective::Require,
237            indirect: false,
238            source: deps_core::parser::DependencySource::Registry,
239        }
240    }
241
242    /// Mock parse result for testing
243    struct MockParseResult {
244        dependencies: Vec<GoDependency>,
245        uri: Uri,
246    }
247
248    /// A dependency on `line`, with a `version_range` there so position-based lookup
249    /// (issue #593) can find it — mirrors `mock_dependency`, but with an explicit `source`.
250    fn dep_with_source(name: &str, source: DependencySource, line: u32) -> GoDependency {
251        GoDependency {
252            module_path: pkg(name),
253            module_path_range: Range::new(Position::new(line, 0), Position::new(line, 0)),
254            version: None,
255            version_range: Some(Range::new(Position::new(line, 0), Position::new(line, 10))),
256            directive: GoDirective::Require,
257            indirect: false,
258            source,
259        }
260    }
261
262    impl deps_core::ParseResult for MockParseResult {
263        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
264            self.dependencies
265                .iter()
266                .map(|d| d as &dyn deps_core::Dependency)
267                .collect()
268        }
269
270        fn workspace_root(&self) -> Option<&std::path::Path> {
271            None
272        }
273
274        fn uri(&self) -> &Uri {
275            &self.uri
276        }
277
278        fn as_any(&self) -> &dyn Any {
279            self
280        }
281    }
282
283    #[test]
284    fn test_ecosystem_id() {
285        let cache = Arc::new(deps_core::HttpCache::new());
286        let ecosystem = GoEcosystem::new(cache);
287        assert_eq!(ecosystem.id(), "go");
288    }
289
290    #[test]
291    fn test_ecosystem_display_name() {
292        let cache = Arc::new(deps_core::HttpCache::new());
293        let ecosystem = GoEcosystem::new(cache);
294        assert_eq!(ecosystem.display_name(), "Go Modules");
295    }
296
297    #[test]
298    fn test_ecosystem_manifest_filenames() {
299        let cache = Arc::new(deps_core::HttpCache::new());
300        let ecosystem = GoEcosystem::new(cache);
301        assert_eq!(ecosystem.manifest_filenames(), &["go.mod"]);
302    }
303
304    #[test]
305    fn test_ecosystem_lockfile_filenames() {
306        let cache = Arc::new(deps_core::HttpCache::new());
307        let ecosystem = GoEcosystem::new(cache);
308        assert_eq!(ecosystem.lockfile_filenames(), &["go.sum"]);
309    }
310
311    #[test]
312    fn test_generate_inlay_hints_up_to_date() {
313        let cache = Arc::new(deps_core::HttpCache::new());
314        let ecosystem = GoEcosystem::new(cache);
315
316        let uri = deps_core::test_util::test_uri("/test/go.mod");
317        let parse_result = MockParseResult {
318            dependencies: vec![mock_dependency(
319                "github.com/gin-gonic/gin",
320                Some("v1.9.1"),
321                5,
322            )],
323            uri,
324        };
325
326        let mut cached_versions = HashMap::new();
327        cached_versions.insert(
328            "github.com/gin-gonic/gin".into(),
329            PackageVersions::latest_only("v1.9.1"),
330        );
331
332        let config = EcosystemConfig {
333            loading_text: "⏳".to_string(),
334            show_loading_hints: true,
335            offline: false,
336            show_up_to_date_hints: true,
337            up_to_date_text: "✅".to_string(),
338            needs_update_text: "❌ {}".to_string(),
339        };
340
341        // Lock file has the latest version
342        let mut resolved_versions = HashMap::new();
343        resolved_versions.insert("github.com/gin-gonic/gin".into(), "v1.9.1".into());
344        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
345            &parse_result,
346            VersionData::new(&cached_versions, &resolved_versions),
347            deps_core::LoadingState::Loaded,
348            &config,
349        ));
350
351        assert_eq!(hints.len(), 1);
352        match &hints[0].label {
353            InlayHintLabel::String(s) => assert_eq!(s, "✅ v1.9.1"),
354            _ => panic!("Expected String label"),
355        }
356    }
357
358    #[test]
359    fn test_generate_inlay_hints_needs_update() {
360        let cache = Arc::new(deps_core::HttpCache::new());
361        let ecosystem = GoEcosystem::new(cache);
362
363        let uri = deps_core::test_util::test_uri("/test/go.mod");
364        let parse_result = MockParseResult {
365            dependencies: vec![mock_dependency(
366                "github.com/gin-gonic/gin",
367                Some("v1.9.0"),
368                5,
369            )],
370            uri,
371        };
372
373        let mut cached_versions = HashMap::new();
374        cached_versions.insert(
375            "github.com/gin-gonic/gin".into(),
376            PackageVersions::latest_only("v1.9.1"),
377        );
378
379        let config = EcosystemConfig {
380            loading_text: "⏳".to_string(),
381            show_loading_hints: true,
382            offline: false,
383            show_up_to_date_hints: true,
384            up_to_date_text: "✅".to_string(),
385            needs_update_text: "❌ {}".to_string(),
386        };
387
388        let resolved_versions = HashMap::new();
389        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
390            &parse_result,
391            VersionData::new(&cached_versions, &resolved_versions),
392            deps_core::LoadingState::Loaded,
393            &config,
394        ));
395
396        assert_eq!(hints.len(), 1);
397        match &hints[0].label {
398            InlayHintLabel::String(s) => assert_eq!(s, "❌ v1.9.1"),
399            _ => panic!("Expected String label"),
400        }
401    }
402
403    #[test]
404    fn test_generate_inlay_hints_hide_up_to_date() {
405        let cache = Arc::new(deps_core::HttpCache::new());
406        let ecosystem = GoEcosystem::new(cache);
407
408        let uri = deps_core::test_util::test_uri("/test/go.mod");
409        let parse_result = MockParseResult {
410            dependencies: vec![mock_dependency(
411                "github.com/gin-gonic/gin",
412                Some("v1.9.1"),
413                5,
414            )],
415            uri,
416        };
417
418        let mut cached_versions = HashMap::new();
419        cached_versions.insert(
420            "github.com/gin-gonic/gin".into(),
421            PackageVersions::latest_only("v1.9.1"),
422        );
423
424        let config = EcosystemConfig {
425            loading_text: "⏳".to_string(),
426            show_loading_hints: true,
427            offline: false,
428            show_up_to_date_hints: false,
429            up_to_date_text: "✅".to_string(),
430            needs_update_text: "❌ {}".to_string(),
431        };
432
433        // Lock file has the latest version - but show_up_to_date_hints is false
434        let mut resolved_versions = HashMap::new();
435        resolved_versions.insert("github.com/gin-gonic/gin".into(), "v1.9.1".into());
436        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
437            &parse_result,
438            VersionData::new(&cached_versions, &resolved_versions),
439            deps_core::LoadingState::Loaded,
440            &config,
441        ));
442
443        assert_eq!(hints.len(), 0);
444    }
445
446    #[test]
447    fn test_generate_inlay_hints_no_version_range() {
448        let cache = Arc::new(deps_core::HttpCache::new());
449        let ecosystem = GoEcosystem::new(cache);
450
451        let mut dep = mock_dependency("github.com/gin-gonic/gin", Some("v1.9.1"), 5);
452        dep.version_range = None;
453
454        let uri = deps_core::test_util::test_uri("/test/go.mod");
455        let parse_result = MockParseResult {
456            dependencies: vec![dep],
457            uri,
458        };
459
460        let mut cached_versions = HashMap::new();
461        cached_versions.insert(
462            "github.com/gin-gonic/gin".into(),
463            PackageVersions::latest_only("v1.9.1"),
464        );
465
466        let config = EcosystemConfig {
467            loading_text: "⏳".to_string(),
468            show_loading_hints: true,
469            offline: false,
470            show_up_to_date_hints: true,
471            up_to_date_text: "✅".to_string(),
472            needs_update_text: "❌ {}".to_string(),
473        };
474
475        let resolved_versions = HashMap::new();
476        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
477            &parse_result,
478            VersionData::new(&cached_versions, &resolved_versions),
479            deps_core::LoadingState::Loaded,
480            &config,
481        ));
482
483        assert_eq!(hints.len(), 0);
484    }
485
486    #[test]
487    fn test_as_any() {
488        let cache = Arc::new(deps_core::HttpCache::new());
489        let ecosystem = GoEcosystem::new(cache);
490
491        // Verify we can downcast
492        let any = ecosystem.as_any();
493        assert!(any.is::<GoEcosystem>());
494    }
495
496    #[tokio::test]
497    async fn test_complete_package_names_empty() {
498        let cache = Arc::new(deps_core::HttpCache::new());
499        let ecosystem = GoEcosystem::new(cache);
500
501        // Go doesn't have package search, should always return empty
502        let results = ecosystem.complete_package_names("github").await;
503        assert!(results.is_empty());
504    }
505
506    #[tokio::test]
507    #[ignore] // Requires network access
508    async fn test_complete_versions_real() {
509        let cache = Arc::new(deps_core::HttpCache::new());
510        let ecosystem = GoEcosystem::new(cache);
511        let dep = mock_dependency("github.com/gin-gonic/gin", Some("v1.9"), 0);
512        let position = dep.version_range.unwrap().start;
513        let parse_result = MockParseResult {
514            dependencies: vec![dep],
515            uri: deps_core::test_util::test_uri("/test/go.mod"),
516        };
517
518        let results = ecosystem
519            .complete_versions(
520                &parse_result,
521                position,
522                "v1.9",
523                deps_core::FreshnessSettings::default(),
524            )
525            .await;
526        assert!(!results.is_empty());
527        assert!(results.iter().all(|r| r.label.starts_with("v1.9")));
528    }
529
530    #[tokio::test]
531    async fn test_complete_versions_unknown_package() {
532        let cache = Arc::new(deps_core::HttpCache::new());
533        let ecosystem = GoEcosystem::new(cache);
534        let dep = mock_dependency("github.com/nonexistent/package12345", Some("v1.0"), 0);
535        let position = dep.version_range.unwrap().start;
536        let parse_result = MockParseResult {
537            dependencies: vec![dep],
538            uri: deps_core::test_util::test_uri("/test/go.mod"),
539        };
540
541        // Unknown package should return empty (graceful degradation)
542        let results = ecosystem
543            .complete_versions(
544                &parse_result,
545                position,
546                "v1.0",
547                deps_core::FreshnessSettings::default(),
548            )
549            .await;
550        assert!(results.is_empty());
551    }
552
553    #[tokio::test]
554    async fn test_complete_features_always_empty() {
555        let cache = Arc::new(deps_core::HttpCache::new());
556        let ecosystem = GoEcosystem::new(cache);
557
558        // Go doesn't have features, should always return empty
559        let results = ecosystem
560            .complete_features(&pkg("github.com/gin-gonic/gin"), "")
561            .await;
562        assert!(results.is_empty());
563    }
564
565    #[tokio::test]
566    #[ignore] // Requires network access
567    async fn test_complete_versions_limit_20() {
568        let cache = Arc::new(deps_core::HttpCache::new());
569        let ecosystem = GoEcosystem::new(cache);
570
571        // Test that we respect the 20 result limit
572        let dep = mock_dependency("github.com/gin-gonic/gin", Some("v1.0"), 0);
573        let position = dep.version_range.unwrap().start;
574        let parse_result = MockParseResult {
575            dependencies: vec![dep],
576            uri: deps_core::test_util::test_uri("/test/go.mod"),
577        };
578        let results = ecosystem
579            .complete_versions(
580                &parse_result,
581                position,
582                "v",
583                deps_core::FreshnessSettings::default(),
584            )
585            .await;
586        assert!(results.len() <= 20);
587    }
588
589    #[tokio::test]
590    async fn test_generate_hover_on_module_path() {
591        let cache = Arc::new(deps_core::HttpCache::new());
592        let ecosystem = GoEcosystem::new(cache);
593
594        let uri = deps_core::test_util::test_uri("/test/go.mod");
595        let parse_result = MockParseResult {
596            dependencies: vec![mock_dependency(
597                "github.com/gin-gonic/gin",
598                Some("v1.9.1"),
599                5,
600            )],
601            uri,
602        };
603
604        let position = Position::new(5, 5);
605        let cached_versions = HashMap::new();
606        let resolved_versions = HashMap::new();
607
608        let hover = ecosystem
609            .generate_hover(
610                &parse_result,
611                position,
612                VersionData::new(&cached_versions, &resolved_versions),
613                deps_core::FreshnessSettings::default(),
614            )
615            .await;
616
617        // Returns hover with package URL
618        assert!(hover.is_some());
619        let hover_content = hover.unwrap();
620        let markdown = format!("{:?}", hover_content.contents);
621        assert!(markdown.contains("pkg.go.dev"));
622    }
623
624    #[tokio::test]
625    async fn test_generate_hover_outside_dependency() {
626        let cache = Arc::new(deps_core::HttpCache::new());
627        let ecosystem = GoEcosystem::new(cache);
628
629        let uri = deps_core::test_util::test_uri("/test/go.mod");
630        let parse_result = MockParseResult {
631            dependencies: vec![mock_dependency(
632                "github.com/gin-gonic/gin",
633                Some("v1.9.1"),
634                5,
635            )],
636            uri,
637        };
638
639        let position = Position::new(0, 0);
640        let cached_versions = HashMap::new();
641        let resolved_versions = HashMap::new();
642
643        let hover = ecosystem
644            .generate_hover(
645                &parse_result,
646                position,
647                VersionData::new(&cached_versions, &resolved_versions),
648                deps_core::FreshnessSettings::default(),
649            )
650            .await;
651
652        assert!(hover.is_none());
653    }
654
655    #[tokio::test]
656    async fn test_generate_code_actions_on_module() {
657        let cache = Arc::new(deps_core::HttpCache::new());
658        let ecosystem = GoEcosystem::new(cache);
659
660        let uri = deps_core::test_util::test_uri("/test/go.mod");
661        let parse_result = MockParseResult {
662            dependencies: vec![mock_dependency(
663                "github.com/gin-gonic/gin",
664                Some("v1.9.0"),
665                5,
666            )],
667            uri: uri.clone(),
668        };
669
670        let position = Position::new(5, 5);
671        let cached_versions = HashMap::new();
672        let resolved_versions = HashMap::new();
673
674        // `version_range` on line 5 spans columns 0..10; content must slice to
675        // exactly the declared requirement text there for the `literal_span_matches`
676        // guard in `generate_code_actions` to accept the edit.
677        let content = "\n\n\n\n\nv1.9.0    \n";
678
679        let actions = ecosystem
680            .generate_code_actions(
681                &parse_result,
682                position,
683                &uri,
684                VersionData::new(&cached_versions, &resolved_versions),
685                content,
686            )
687            .await;
688
689        // Returns actions (open documentation link)
690        assert!(!actions.is_empty());
691    }
692
693    #[tokio::test]
694    #[ignore = "Requires network access to proxy.golang.org"]
695    async fn test_generate_diagnostics_basic() {
696        let cache = Arc::new(deps_core::HttpCache::new());
697        let ecosystem = GoEcosystem::new(cache);
698
699        let uri = deps_core::test_util::test_uri("/test/go.mod");
700        let parse_result = MockParseResult {
701            dependencies: vec![mock_dependency(
702                "github.com/gin-gonic/gin",
703                Some("v1.9.1"),
704                5,
705            )],
706            uri,
707        };
708
709        let cached_versions = HashMap::new();
710        let resolved_versions = HashMap::new();
711
712        // Use timeout to prevent hanging
713        let result = tokio::time::timeout(
714            std::time::Duration::from_secs(5),
715            ecosystem.generate_diagnostics(
716                &parse_result,
717                VersionData::new(&cached_versions, &resolved_versions),
718                parse_result.uri(),
719                deps_core::FreshnessSettings::default(),
720                deps_core::DiagnosticSeverities::default(),
721            ),
722        )
723        .await;
724
725        // Should complete within timeout
726        assert!(result.is_ok(), "Diagnostic generation timed out");
727    }
728
729    #[tokio::test]
730    async fn test_generate_completions_package_name() {
731        let cache = Arc::new(deps_core::HttpCache::new());
732        let ecosystem = GoEcosystem::new(cache);
733
734        let content = r"module example.com/myapp
735
736go 1.21
737
738require github.com/
739";
740
741        let uri = deps_core::test_util::test_uri("/test/go.mod");
742        let parse_result = MockParseResult {
743            dependencies: vec![],
744            uri,
745        };
746
747        let position = Position::new(4, 19);
748
749        let completions = ecosystem
750            .generate_completions(
751                &parse_result,
752                position,
753                content,
754                deps_core::FreshnessSettings::default(),
755            )
756            .await;
757
758        // Go doesn't support package search, should be empty
759        assert!(completions.items.is_empty());
760    }
761
762    #[tokio::test]
763    async fn test_generate_completions_outside_context() {
764        let cache = Arc::new(deps_core::HttpCache::new());
765        let ecosystem = GoEcosystem::new(cache);
766
767        let content = r"module example.com/myapp
768
769go 1.21
770";
771
772        let uri = deps_core::test_util::test_uri("/test/go.mod");
773        let parse_result = MockParseResult {
774            dependencies: vec![],
775            uri,
776        };
777
778        let position = Position::new(0, 0);
779
780        let completions = ecosystem
781            .generate_completions(
782                &parse_result,
783                position,
784                content,
785                deps_core::FreshnessSettings::default(),
786            )
787            .await;
788
789        assert!(completions.items.is_empty());
790    }
791
792    #[tokio::test]
793    async fn test_parse_manifest_valid() {
794        let cache = Arc::new(deps_core::HttpCache::new());
795        let ecosystem = GoEcosystem::new(cache);
796
797        let content = r"module example.com/myapp
798
799go 1.21
800
801require github.com/gin-gonic/gin v1.9.1
802";
803
804        let uri = deps_core::test_util::test_uri("/test/go.mod");
805
806        let result = ecosystem.parse_manifest(content, &uri).await;
807        assert!(result.is_ok());
808
809        let parse_result = result.unwrap();
810        assert_eq!(parse_result.dependencies().len(), 1);
811        assert_eq!(
812            parse_result.dependencies()[0].name(),
813            "github.com/gin-gonic/gin"
814        );
815    }
816
817    #[tokio::test]
818    async fn test_parse_manifest_empty() {
819        let cache = Arc::new(deps_core::HttpCache::new());
820        let ecosystem = GoEcosystem::new(cache);
821
822        let content = "";
823        let uri = deps_core::test_util::test_uri("/test/go.mod");
824
825        let result = ecosystem.parse_manifest(content, &uri).await;
826        assert!(result.is_ok());
827
828        let parse_result = result.unwrap();
829        assert_eq!(parse_result.dependencies().len(), 0);
830    }
831
832    #[test]
833    fn test_registry_returns_trait_object() {
834        let cache = Arc::new(deps_core::HttpCache::new());
835        let ecosystem = GoEcosystem::new(cache);
836
837        let registry = ecosystem.registry();
838        assert!(registry.as_any().is::<GoRegistry>());
839    }
840
841    #[test]
842    fn test_lockfile_provider_exists() {
843        let cache = Arc::new(deps_core::HttpCache::new());
844        let ecosystem = GoEcosystem::new(cache);
845
846        assert!(ecosystem.lockfile_provider().is_some());
847    }
848
849    #[test]
850    fn test_mock_dependency_indirect() {
851        let mut dep = mock_dependency("github.com/example/pkg", Some("v1.0.0"), 10);
852        dep.indirect = true;
853
854        assert!(dep.indirect);
855        assert_eq!(dep.name(), "github.com/example/pkg");
856    }
857
858    // --- issue #593: completion routes by cursor position, not by resolved DependencySource name ---
859
860    /// Two dependencies sharing one `PackageName` but resolving to different sources no
861    /// longer collapse into the old name-based "offer nothing for either" result (spec 034
862    /// F1's `CompletionSource::Ambiguous`) — cursor position now identifies exactly one
863    /// dependency, so each occurrence routes independently through its own source.
864    #[tokio::test]
865    async fn test_complete_versions_same_name_different_sources_routes_by_position() {
866        let cache = Arc::new(deps_core::HttpCache::new());
867        let ecosystem = GoEcosystem::new(cache);
868        let registry_dep = dep_with_source("git.mycorp.example/pkg", DependencySource::Registry, 0);
869        let alternate_dep = dep_with_source(
870            "git.mycorp.example/pkg",
871            DependencySource::AlternateRegistry {
872                index: "go-private:never-registered".to_string(),
873                mirrors_crates_io: false,
874            },
875            1,
876        );
877        let alternate_position = alternate_dep.version_range.unwrap().start;
878        let parse_result = MockParseResult {
879            dependencies: vec![registry_dep, alternate_dep],
880            uri: deps_core::test_util::test_uri("/test/go.mod"),
881        };
882
883        // The alternate occurrence resolves deterministically without network: its index was
884        // never registered, so the fetch fails closed with `PackageNotFound` before any HTTP
885        // call — proving its own source, not the co-occurring `Registry`-sourced entry, drove
886        // the routing.
887        let results = ecosystem
888            .complete_versions(
889                &parse_result,
890                alternate_position,
891                "v1.",
892                deps_core::FreshnessSettings::default(),
893            )
894            .await;
895        assert!(
896            results.is_empty(),
897            "unregistered alternate index must offer no completions"
898        );
899    }
900
901    /// An `AlternateRegistry` source whose index has no registered client offers no
902    /// completions — never a fall back to `proxy.golang.org` (the core of F1).
903    #[tokio::test]
904    async fn test_complete_versions_unregistered_alternate_offers_nothing() {
905        let cache = Arc::new(deps_core::HttpCache::new());
906        let ecosystem = GoEcosystem::new(cache);
907        let dep = dep_with_source(
908            "git.mycorp.example/internal/auth",
909            DependencySource::AlternateRegistry {
910                index: "never-registered".to_string(),
911                mirrors_crates_io: false,
912            },
913            0,
914        );
915        let position = dep.version_range.unwrap().start;
916        let parse_result = MockParseResult {
917            dependencies: vec![dep],
918            uri: deps_core::test_util::test_uri("/test/go.mod"),
919        };
920        let results = ecosystem
921            .complete_versions(
922                &parse_result,
923                position,
924                "v1.",
925                deps_core::FreshnessSettings::default(),
926            )
927            .await;
928        assert!(results.is_empty());
929    }
930
931    /// F1 end-to-end: a registered alternate client's version completion routes there,
932    /// proving completion actually consults the resolved `$GOENV` chain instead of always
933    /// querying the public root.
934    #[tokio::test]
935    async fn test_complete_versions_routes_to_registered_alternate_client() {
936        use crate::config::{GoProxyChain, GoProxyHop, GoProxyUrl};
937        use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
938
939        let mut alt_server = mockito::Server::new_async().await;
940        alt_server
941            .mock("GET", "/git.mycorp.example/internal/auth/@v/list")
942            .with_status(200)
943            .with_body("v1.0.0\nv1.5.0\n")
944            .create_async()
945            .await;
946
947        let cache = Arc::new(deps_core::HttpCache::new());
948        cache.set_registry_policy(WorkspaceRegistryAccess::All);
949        let registry = Arc::new(GoRegistry::new(Arc::clone(&cache)));
950        let ecosystem = GoEcosystem::with_context(Arc::clone(&registry), GoParseContext::default());
951
952        let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::All);
953        let chain = GoProxyChain {
954            key: "go-proxy:test".to_string(),
955            hops: vec![GoProxyHop::Url(
956                GoProxyUrl::new(&alt_server.url(), &policy).unwrap(),
957            )],
958            ..Default::default()
959        };
960        GoRegistry::register_chain(&registry, &chain);
961
962        let dep = dep_with_source(
963            "git.mycorp.example/internal/auth",
964            DependencySource::AlternateRegistry {
965                index: "go-proxy:test".to_string(),
966                mirrors_crates_io: false,
967            },
968            0,
969        );
970        let position = dep.version_range.unwrap().start;
971        let parse_result = MockParseResult {
972            dependencies: vec![dep],
973            uri: deps_core::test_util::test_uri("/test/go.mod"),
974        };
975        let results = ecosystem
976            .complete_versions(
977                &parse_result,
978                position,
979                "v1.",
980                deps_core::FreshnessSettings::default(),
981            )
982            .await;
983        assert!(!results.is_empty());
984    }
985}