Skip to main content

deps_cargo/
ecosystem.rs

1//! Cargo ecosystem implementation for deps-lsp.
2//!
3//! This module implements the `Ecosystem` trait for Cargo/Rust projects,
4//! providing LSP functionality for `Cargo.toml` files.
5
6use std::any::Any;
7use std::sync::Arc;
8use tower_lsp_server::ls_types::{CompletionItem, Position, Uri};
9
10use deps_core::{
11    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, Version,
12    lsp_helpers::EcosystemFormatter,
13};
14
15use crate::formatter::CargoFormatter;
16use crate::registry::CratesIoRegistry;
17
18/// Cargo ecosystem implementation.
19///
20/// Provides LSP functionality for Cargo.toml files, including:
21/// - Dependency parsing with position tracking
22/// - Version information from crates.io
23/// - Inlay hints for latest versions
24/// - Hover tooltips with package metadata
25/// - Code actions for version updates
26/// - Diagnostics for unknown/yanked packages
27pub struct CargoEcosystem {
28    registry: Arc<CratesIoRegistry>,
29    formatter: CargoFormatter,
30}
31
32impl CargoEcosystem {
33    /// Creates a new Cargo ecosystem with the given HTTP cache.
34    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
35        Self {
36            registry: Arc::new(CratesIoRegistry::new(cache)),
37            formatter: CargoFormatter,
38        }
39    }
40
41    async fn complete_package_names(&self, prefix: &str) -> Vec<CompletionItem> {
42        deps_core::completion::complete_package_names_generic(self.registry.as_ref(), prefix, 20)
43            .await
44    }
45
46    async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
47        deps_core::completion::complete_versions_generic(
48            self.registry.as_ref(),
49            package_name,
50            prefix,
51            &['^', '~', '=', '<', '>'],
52        )
53        .await
54    }
55
56    /// Completes feature flags for a specific package.
57    ///
58    /// Fetches features from the latest stable version.
59    async fn complete_features(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
60        use deps_core::completion::build_feature_completion;
61
62        // Fetch all versions to find latest stable
63        let versions = match self.registry.get_versions(package_name).await {
64            Ok(v) => v,
65            Err(e) => {
66                tracing::warn!("Failed to fetch versions for '{}': {}", package_name, e);
67                return vec![];
68            }
69        };
70
71        let latest = match versions.iter().find(|v| v.is_stable()) {
72            Some(v) => v,
73            None => {
74                tracing::warn!("No stable version found for '{}'", package_name);
75                return vec![];
76            }
77        };
78
79        // Get features and filter by prefix
80        let features = latest.features();
81        features
82            .into_iter()
83            .filter(|f| f.starts_with(prefix))
84            .map(|feature| build_feature_completion(&feature, package_name, None))
85            .collect()
86    }
87}
88
89impl deps_core::ecosystem::private::Sealed for CargoEcosystem {}
90
91impl Ecosystem for CargoEcosystem {
92    fn id(&self) -> &'static str {
93        "cargo"
94    }
95
96    fn display_name(&self) -> &'static str {
97        "Cargo (Rust)"
98    }
99
100    fn manifest_filenames(&self) -> &[&'static str] {
101        &["Cargo.toml"]
102    }
103
104    fn lockfile_filenames(&self) -> &[&'static str] {
105        &["Cargo.lock"]
106    }
107
108    fn parse_manifest<'a>(
109        &'a self,
110        content: &'a str,
111        uri: &'a Uri,
112    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
113        Box::pin(async move {
114            let result = crate::parser::parse_cargo_toml(content, uri)?;
115            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
116        })
117    }
118
119    fn registry(&self) -> Arc<dyn Registry> {
120        self.registry.clone() as Arc<dyn Registry>
121    }
122
123    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
124        Some(Arc::new(crate::lockfile::CargoLockParser))
125    }
126
127    fn formatter(&self) -> &dyn EcosystemFormatter {
128        &self.formatter
129    }
130
131    fn generate_completions<'a>(
132        &'a self,
133        parse_result: &'a dyn ParseResultTrait,
134        position: Position,
135        content: &'a str,
136    ) -> deps_core::ecosystem::BoxFuture<'a, Vec<CompletionItem>> {
137        Box::pin(async move {
138            use deps_core::completion::{CompletionContext, detect_completion_context};
139
140            let context = detect_completion_context(parse_result, position, content);
141
142            match context {
143                CompletionContext::PackageName { prefix } => {
144                    self.complete_package_names(&prefix).await
145                }
146                CompletionContext::Version {
147                    package_name,
148                    prefix,
149                } => self.complete_versions(&package_name, &prefix).await,
150                CompletionContext::Feature {
151                    package_name,
152                    prefix,
153                } => self.complete_features(&package_name, &prefix).await,
154                CompletionContext::None => vec![],
155            }
156        })
157    }
158
159    fn as_any(&self) -> &dyn Any {
160        self
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::types::{DependencySection, DependencySource, ParsedDependency};
168    use deps_core::EcosystemConfig;
169    use std::collections::HashMap;
170    use tower_lsp_server::ls_types::{InlayHintLabel, Position, Range};
171
172    /// Mock dependency for testing
173    fn mock_dependency(
174        name: &str,
175        version: Option<&str>,
176        name_line: u32,
177        version_line: u32,
178    ) -> ParsedDependency {
179        ParsedDependency {
180            name: name.to_string(),
181            name_range: Range::new(
182                Position::new(name_line, 0),
183                Position::new(name_line, name.len() as u32),
184            ),
185            version_req: version.map(String::from),
186            version_range: version.map(|_| {
187                Range::new(
188                    Position::new(version_line, 0),
189                    Position::new(version_line, 10),
190                )
191            }),
192            features: vec![],
193            features_range: None,
194            source: DependencySource::Registry,
195            section: DependencySection::Dependencies,
196        }
197    }
198
199    /// Mock parse result for testing
200    struct MockParseResult {
201        dependencies: Vec<ParsedDependency>,
202    }
203
204    impl deps_core::ParseResult for MockParseResult {
205        fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
206            self.dependencies
207                .iter()
208                .map(|d| d as &dyn deps_core::Dependency)
209                .collect()
210        }
211
212        fn workspace_root(&self) -> Option<&std::path::Path> {
213            None
214        }
215
216        fn uri(&self) -> &Uri {
217            static URI: std::sync::LazyLock<Uri> =
218                std::sync::LazyLock::new(|| Uri::from_file_path("/test/Cargo.toml").unwrap());
219            &URI
220        }
221
222        fn as_any(&self) -> &dyn Any {
223            self
224        }
225    }
226
227    #[test]
228    fn test_ecosystem_id() {
229        let cache = Arc::new(deps_core::HttpCache::new());
230        let ecosystem = CargoEcosystem::new(cache);
231        assert_eq!(ecosystem.id(), "cargo");
232    }
233
234    #[test]
235    fn test_ecosystem_display_name() {
236        let cache = Arc::new(deps_core::HttpCache::new());
237        let ecosystem = CargoEcosystem::new(cache);
238        assert_eq!(ecosystem.display_name(), "Cargo (Rust)");
239    }
240
241    #[test]
242    fn test_ecosystem_manifest_filenames() {
243        let cache = Arc::new(deps_core::HttpCache::new());
244        let ecosystem = CargoEcosystem::new(cache);
245        assert_eq!(ecosystem.manifest_filenames(), &["Cargo.toml"]);
246    }
247
248    #[test]
249    fn test_ecosystem_lockfile_filenames() {
250        let cache = Arc::new(deps_core::HttpCache::new());
251        let ecosystem = CargoEcosystem::new(cache);
252        assert_eq!(ecosystem.lockfile_filenames(), &["Cargo.lock"]);
253    }
254
255    #[test]
256    fn test_generate_inlay_hints_up_to_date_exact_match() {
257        let cache = Arc::new(deps_core::HttpCache::new());
258        let ecosystem = CargoEcosystem::new(cache);
259
260        let parse_result = MockParseResult {
261            dependencies: vec![mock_dependency("serde", Some("1.0.214"), 5, 5)],
262        };
263
264        let mut cached_versions = HashMap::new();
265        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
266
267        let config = EcosystemConfig {
268            loading_text: "⏳".to_string(),
269            show_loading_hints: true,
270            show_up_to_date_hints: true,
271            up_to_date_text: "✅".to_string(),
272            needs_update_text: "❌ {}".to_string(),
273        };
274
275        // Lock file has the latest version
276        let mut resolved_versions = HashMap::new();
277        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
278        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
279            &parse_result,
280            &cached_versions,
281            &resolved_versions,
282            deps_core::LoadingState::Loaded,
283            &config,
284        ));
285
286        assert_eq!(hints.len(), 1);
287        match &hints[0].label {
288            InlayHintLabel::String(s) => assert_eq!(s, "✅ 1.0.214"),
289            _ => panic!("Expected String label"),
290        }
291    }
292
293    #[test]
294    fn test_generate_inlay_hints_up_to_date_caret_version() {
295        let cache = Arc::new(deps_core::HttpCache::new());
296        let ecosystem = CargoEcosystem::new(cache);
297
298        let parse_result = MockParseResult {
299            dependencies: vec![mock_dependency("serde", Some("^1.0"), 5, 5)],
300        };
301
302        let mut cached_versions = HashMap::new();
303        cached_versions.insert("serde".to_string(), "1.0.214".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("serde".to_string(), "1.0.214".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, "✅ 1.0.214"),
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 = CargoEcosystem::new(cache);
335
336        let parse_result = MockParseResult {
337            dependencies: vec![mock_dependency("serde", Some("1.0.100"), 5, 5)],
338        };
339
340        let mut cached_versions = HashMap::new();
341        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
342
343        let config = EcosystemConfig {
344            loading_text: "⏳".to_string(),
345            show_loading_hints: true,
346            show_up_to_date_hints: true,
347            up_to_date_text: "✅".to_string(),
348            needs_update_text: "❌ {}".to_string(),
349        };
350
351        let resolved_versions = HashMap::new();
352        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
353            &parse_result,
354            &cached_versions,
355            &resolved_versions,
356            deps_core::LoadingState::Loaded,
357            &config,
358        ));
359
360        assert_eq!(hints.len(), 1);
361        match &hints[0].label {
362            InlayHintLabel::String(s) => assert_eq!(s, "❌ 1.0.214"),
363            _ => panic!("Expected String label"),
364        }
365    }
366
367    #[test]
368    fn test_generate_inlay_hints_hide_up_to_date() {
369        let cache = Arc::new(deps_core::HttpCache::new());
370        let ecosystem = CargoEcosystem::new(cache);
371
372        let parse_result = MockParseResult {
373            dependencies: vec![mock_dependency("serde", Some("1.0.214"), 5, 5)],
374        };
375
376        let mut cached_versions = HashMap::new();
377        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
378
379        let config = EcosystemConfig {
380            loading_text: "⏳".to_string(),
381            show_loading_hints: true,
382            show_up_to_date_hints: false,
383            up_to_date_text: "✅".to_string(),
384            needs_update_text: "❌ {}".to_string(),
385        };
386
387        // Lock file has the latest version - but show_up_to_date_hints is false
388        let mut resolved_versions = HashMap::new();
389        resolved_versions.insert("serde".to_string(), "1.0.214".to_string());
390        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
391            &parse_result,
392            &cached_versions,
393            &resolved_versions,
394            deps_core::LoadingState::Loaded,
395            &config,
396        ));
397
398        assert_eq!(hints.len(), 0);
399    }
400
401    #[test]
402    fn test_generate_inlay_hints_no_version_range() {
403        let cache = Arc::new(deps_core::HttpCache::new());
404        let ecosystem = CargoEcosystem::new(cache);
405
406        let mut dep = mock_dependency("serde", Some("1.0.214"), 5, 5);
407        dep.version_range = None;
408
409        let parse_result = MockParseResult {
410            dependencies: vec![dep],
411        };
412
413        let mut cached_versions = HashMap::new();
414        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
415
416        let config = EcosystemConfig {
417            loading_text: "⏳".to_string(),
418            show_loading_hints: true,
419            show_up_to_date_hints: true,
420            up_to_date_text: "✅".to_string(),
421            needs_update_text: "❌ {}".to_string(),
422        };
423
424        let resolved_versions = HashMap::new();
425        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
426            &parse_result,
427            &cached_versions,
428            &resolved_versions,
429            deps_core::LoadingState::Loaded,
430            &config,
431        ));
432
433        assert_eq!(hints.len(), 0);
434    }
435
436    #[test]
437    fn test_generate_inlay_hints_caret_edge_case() {
438        let cache = Arc::new(deps_core::HttpCache::new());
439        let ecosystem = CargoEcosystem::new(cache);
440
441        // Edge case: version_req is just "^" without version number
442        let dep = mock_dependency("serde", Some("^"), 5, 5);
443
444        let parse_result = MockParseResult {
445            dependencies: vec![dep],
446        };
447
448        let mut cached_versions = HashMap::new();
449        cached_versions.insert("serde".to_string(), "1.0.214".to_string());
450
451        let config = EcosystemConfig {
452            loading_text: "⏳".to_string(),
453            show_loading_hints: true,
454            show_up_to_date_hints: true,
455            up_to_date_text: "✅".to_string(),
456            needs_update_text: "❌ {}".to_string(),
457        };
458
459        // Should not panic, should return update hint
460        let resolved_versions = HashMap::new();
461        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
462            &parse_result,
463            &cached_versions,
464            &resolved_versions,
465            deps_core::LoadingState::Loaded,
466            &config,
467        ));
468
469        assert_eq!(hints.len(), 1);
470    }
471
472    #[test]
473    fn test_as_any() {
474        let cache = Arc::new(deps_core::HttpCache::new());
475        let ecosystem = CargoEcosystem::new(cache);
476
477        // Verify we can downcast
478        let any = ecosystem.as_any();
479        assert!(any.is::<CargoEcosystem>());
480    }
481
482    #[tokio::test]
483    async fn test_complete_package_names_minimum_prefix() {
484        let cache = Arc::new(deps_core::HttpCache::new());
485        let ecosystem = CargoEcosystem::new(cache);
486
487        // Less than 2 characters should return empty
488        let results = ecosystem.complete_package_names("s").await;
489        assert!(results.is_empty());
490
491        // Empty prefix should return empty
492        let results = ecosystem.complete_package_names("").await;
493        assert!(results.is_empty());
494    }
495
496    #[tokio::test]
497    #[ignore] // Requires network access
498    async fn test_complete_package_names_real_search() {
499        let cache = Arc::new(deps_core::HttpCache::new());
500        let ecosystem = CargoEcosystem::new(cache);
501
502        let results = ecosystem.complete_package_names("serd").await;
503        assert!(!results.is_empty());
504        assert!(results.iter().any(|r| r.label == "serde"));
505    }
506
507    #[tokio::test]
508    #[ignore] // Requires network access
509    async fn test_complete_versions_real() {
510        let cache = Arc::new(deps_core::HttpCache::new());
511        let ecosystem = CargoEcosystem::new(cache);
512
513        let results = ecosystem.complete_versions("serde", "1.0").await;
514        assert!(!results.is_empty());
515        assert!(results.iter().all(|r| r.label.starts_with("1.0")));
516    }
517
518    #[tokio::test]
519    #[ignore] // Requires network access
520    async fn test_complete_versions_with_operator() {
521        let cache = Arc::new(deps_core::HttpCache::new());
522        let ecosystem = CargoEcosystem::new(cache);
523
524        let results = ecosystem.complete_versions("serde", "^1.0").await;
525        assert!(!results.is_empty());
526        assert!(results.iter().all(|r| r.label.starts_with("1.0")));
527    }
528
529    #[tokio::test]
530    #[ignore] // Requires network access
531    async fn test_complete_features_real() {
532        let cache = Arc::new(deps_core::HttpCache::new());
533        let ecosystem = CargoEcosystem::new(cache);
534
535        let results = ecosystem.complete_features("serde", "").await;
536        assert!(!results.is_empty());
537        assert!(results.iter().any(|r| r.label == "derive"));
538    }
539
540    #[tokio::test]
541    #[ignore] // Requires network access
542    async fn test_complete_features_with_prefix() {
543        let cache = Arc::new(deps_core::HttpCache::new());
544        let ecosystem = CargoEcosystem::new(cache);
545
546        let results = ecosystem.complete_features("serde", "der").await;
547        assert!(!results.is_empty());
548        assert!(results.iter().all(|r| r.label.starts_with("der")));
549    }
550
551    #[tokio::test]
552    async fn test_complete_versions_unknown_package() {
553        let cache = Arc::new(deps_core::HttpCache::new());
554        let ecosystem = CargoEcosystem::new(cache);
555
556        // Unknown package should return empty (graceful degradation)
557        let results = ecosystem
558            .complete_versions("this-package-does-not-exist-12345", "1.0")
559            .await;
560        assert!(results.is_empty());
561    }
562
563    #[tokio::test]
564    async fn test_complete_features_unknown_package() {
565        let cache = Arc::new(deps_core::HttpCache::new());
566        let ecosystem = CargoEcosystem::new(cache);
567
568        // Unknown package should return empty (graceful degradation)
569        let results = ecosystem
570            .complete_features("this-package-does-not-exist-12345", "")
571            .await;
572        assert!(results.is_empty());
573    }
574
575    #[tokio::test]
576    async fn test_complete_package_names_special_characters() {
577        let cache = Arc::new(deps_core::HttpCache::new());
578        let ecosystem = CargoEcosystem::new(cache);
579
580        // Package names with hyphens and underscores should work
581        let results = ecosystem.complete_package_names("tokio-ut").await;
582        // Should not panic or error
583        assert!(results.is_empty() || !results.is_empty());
584    }
585
586    #[tokio::test]
587    async fn test_complete_package_names_max_length() {
588        let cache = Arc::new(deps_core::HttpCache::new());
589        let ecosystem = CargoEcosystem::new(cache);
590
591        // Prefix longer than 200 chars should return empty (security)
592        let long_prefix = "a".repeat(201);
593        let results = ecosystem.complete_package_names(&long_prefix).await;
594        assert!(results.is_empty());
595
596        // Exactly 100 chars should work
597        let max_prefix = "a".repeat(100);
598        let results = ecosystem.complete_package_names(&max_prefix).await;
599        // Should not panic, but may return empty (no matches)
600        assert!(results.is_empty() || !results.is_empty());
601    }
602
603    #[tokio::test]
604    #[ignore] // Requires network access
605    async fn test_complete_versions_limit_20() {
606        let cache = Arc::new(deps_core::HttpCache::new());
607        let ecosystem = CargoEcosystem::new(cache);
608
609        // Test that we respect the 20 result limit
610        let results = ecosystem.complete_versions("serde", "1").await;
611        assert!(results.len() <= 20);
612    }
613
614    #[tokio::test]
615    #[ignore] // Requires network access
616    async fn test_complete_features_empty_list() {
617        let cache = Arc::new(deps_core::HttpCache::new());
618        let ecosystem = CargoEcosystem::new(cache);
619
620        // Some packages have no features - should handle gracefully
621        // (Using a package that likely has no features, or empty prefix on a small package)
622        let results = ecosystem.complete_features("anyhow", "nonexistent").await;
623        assert!(results.is_empty());
624    }
625
626    #[tokio::test]
627    #[ignore] // Requires network access
628    async fn test_complete_package_names_special_chars_real() {
629        let cache = Arc::new(deps_core::HttpCache::new());
630        let ecosystem = CargoEcosystem::new(cache);
631
632        // Real packages with special characters
633        let results = ecosystem.complete_package_names("tokio-ut").await;
634        assert!(!results.is_empty());
635        assert!(results.iter().any(|r| r.label.contains('-')));
636    }
637
638    #[test]
639    fn test_generate_inlay_hints_loading_state() {
640        let cache = Arc::new(deps_core::HttpCache::new());
641        let ecosystem = CargoEcosystem::new(cache);
642
643        let parse_result = MockParseResult {
644            dependencies: vec![mock_dependency("tokio", Some("1.0"), 5, 5)],
645        };
646
647        // Empty caches - simulating loading state
648        let cached_versions = HashMap::new();
649        let resolved_versions = HashMap::new();
650
651        let config = EcosystemConfig {
652            loading_text: "⏳".to_string(),
653            show_loading_hints: true,
654            show_up_to_date_hints: true,
655            up_to_date_text: "✅".to_string(),
656            needs_update_text: "❌ {}".to_string(),
657        };
658
659        let hints = tokio_test::block_on(ecosystem.generate_inlay_hints(
660            &parse_result,
661            &cached_versions,
662            &resolved_versions,
663            deps_core::LoadingState::Loading,
664            &config,
665        ));
666
667        assert_eq!(hints.len(), 1);
668        match &hints[0].label {
669            InlayHintLabel::String(s) => assert_eq!(s, "⏳", "Expected loading indicator"),
670            _ => panic!("Expected String label"),
671        }
672
673        if let Some(tower_lsp_server::ls_types::InlayHintTooltip::String(tooltip)) =
674            &hints[0].tooltip
675        {
676            assert_eq!(tooltip, "Fetching latest version...");
677        } else {
678            panic!("Expected tooltip for loading state");
679        }
680    }
681}