Skip to main content

deps_bundler/
ecosystem.rs

1//! Bundler ecosystem implementation for deps-lsp.
2
3use std::any::Any;
4use std::sync::Arc;
5use tower_lsp_server::ls_types::{CompletionItem, Position, Range, Uri};
6
7use deps_core::{
8    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, completion::Completions,
9    lsp_helpers::EcosystemFormatter,
10};
11
12use crate::formatter::BundlerFormatter;
13use crate::registry::RubyGemsRegistry;
14
15/// Bundler ecosystem implementation.
16///
17/// Provides LSP functionality for Gemfile files, including:
18/// - Dependency parsing with position tracking
19/// - Version information from rubygems.org
20/// - Inlay hints for latest versions
21/// - Hover tooltips with gem metadata
22/// - Code actions for version updates
23/// - Diagnostics for unknown/yanked gems
24pub struct BundlerEcosystem {
25    registry: Arc<RubyGemsRegistry>,
26    formatter: BundlerFormatter,
27}
28
29impl BundlerEcosystem {
30    /// Creates a new Bundler ecosystem with the given HTTP cache.
31    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
32        Self {
33            registry: Arc::new(RubyGemsRegistry::new(cache)),
34            formatter: BundlerFormatter,
35        }
36    }
37
38    async fn complete_package_names(&self, prefix: &str, range: Range) -> Vec<CompletionItem> {
39        deps_core::completion::complete_package_names_generic(
40            self.registry.as_ref(),
41            prefix,
42            20,
43            range,
44        )
45        .await
46    }
47
48    async fn complete_versions(
49        &self,
50        package_name: &deps_core::PackageName,
51        prefix: &str,
52        freshness: deps_core::FreshnessSettings,
53    ) -> Vec<CompletionItem> {
54        deps_core::completion::complete_versions_generic(
55            self.registry.as_ref(),
56            package_name,
57            prefix,
58            &['~', '>', '<', '=', '!'],
59            freshness,
60        )
61        .await
62    }
63}
64
65impl deps_core::ecosystem::private::Sealed for BundlerEcosystem {}
66
67impl Ecosystem for BundlerEcosystem {
68    fn id(&self) -> &'static str {
69        "bundler"
70    }
71
72    fn display_name(&self) -> &'static str {
73        "Bundler (Ruby)"
74    }
75
76    fn manifest_filenames(&self) -> &[&'static str] {
77        &["Gemfile"]
78    }
79
80    fn lockfile_filenames(&self) -> &[&'static str] {
81        &["Gemfile.lock"]
82    }
83
84    fn parse_manifest<'a>(
85        &'a self,
86        content: &'a str,
87        uri: &'a Uri,
88    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
89        Box::pin(async move {
90            let result = crate::parser::parse_gemfile(content, uri)?;
91            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
92        })
93    }
94
95    fn registry(&self) -> Arc<dyn Registry> {
96        self.registry.clone() as Arc<dyn Registry>
97    }
98
99    fn lockfile_provider(&self) -> Option<Arc<dyn deps_core::lockfile::LockFileProvider>> {
100        Some(Arc::new(crate::lockfile::GemfileLockParser))
101    }
102
103    fn formatter(&self) -> &dyn EcosystemFormatter {
104        &self.formatter
105    }
106
107    fn generate_completions<'a>(
108        &'a self,
109        parse_result: &'a dyn ParseResultTrait,
110        position: Position,
111        content: &'a str,
112        freshness: deps_core::FreshnessSettings,
113    ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
114        Box::pin(async move {
115            use deps_core::completion::{CompletionContext, detect_completion_context};
116
117            let context = detect_completion_context(parse_result, position, content);
118
119            match context {
120                CompletionContext::PackageName { prefix, range } => {
121                    self.complete_package_names(&prefix, range).await
122                }
123                CompletionContext::Version {
124                    package_name,
125                    prefix,
126                } => {
127                    self.complete_versions(&package_name, &prefix, freshness)
128                        .await
129                }
130                CompletionContext::Feature { .. } | CompletionContext::None => vec![],
131            }
132            .into()
133        })
134    }
135
136    fn as_any(&self) -> &dyn Any {
137        self
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_ecosystem_id() {
147        let cache = Arc::new(deps_core::HttpCache::new());
148        let ecosystem = BundlerEcosystem::new(cache);
149        assert_eq!(ecosystem.id(), "bundler");
150    }
151
152    #[test]
153    fn test_ecosystem_display_name() {
154        let cache = Arc::new(deps_core::HttpCache::new());
155        let ecosystem = BundlerEcosystem::new(cache);
156        assert_eq!(ecosystem.display_name(), "Bundler (Ruby)");
157    }
158
159    #[test]
160    fn test_ecosystem_manifest_filenames() {
161        let cache = Arc::new(deps_core::HttpCache::new());
162        let ecosystem = BundlerEcosystem::new(cache);
163        assert_eq!(ecosystem.manifest_filenames(), &["Gemfile"]);
164    }
165
166    #[test]
167    fn test_ecosystem_lockfile_filenames() {
168        let cache = Arc::new(deps_core::HttpCache::new());
169        let ecosystem = BundlerEcosystem::new(cache);
170        assert_eq!(ecosystem.lockfile_filenames(), &["Gemfile.lock"]);
171    }
172
173    #[test]
174    fn test_as_any() {
175        let cache = Arc::new(deps_core::HttpCache::new());
176        let ecosystem = BundlerEcosystem::new(cache);
177        let any = ecosystem.as_any();
178        assert!(any.is::<BundlerEcosystem>());
179    }
180
181    #[tokio::test]
182    async fn test_package_name_completion_context_has_real_range() {
183        // Regression test for #232: the textEdit range for a package-name completion
184        // must be the real name token span, not the (0,0)-(0,0) placeholder.
185        let cache = Arc::new(deps_core::HttpCache::new());
186        let ecosystem = BundlerEcosystem::new(cache);
187        let content = "source 'https://rubygems.org'\ngem 'rails', '~> 7.0'";
188        let uri = deps_core::test_util::test_uri("/test/Gemfile");
189
190        let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
191        let position = Position::new(1, 7); // cursor after "ra" in "rails"
192
193        let context = deps_core::completion::detect_completion_context(
194            parse_result.as_ref(),
195            position,
196            content,
197        );
198
199        match context {
200            deps_core::completion::CompletionContext::PackageName { prefix, range } => {
201                assert_eq!(prefix, "ra");
202                assert_ne!(range, Range::default());
203                assert_eq!(range, Range::new(Position::new(1, 5), Position::new(1, 10)));
204            }
205            other => panic!("Expected PackageName context, got {other:?}"),
206        }
207    }
208
209    #[tokio::test]
210    async fn test_complete_package_names_minimum_prefix() {
211        let cache = Arc::new(deps_core::HttpCache::new());
212        let ecosystem = BundlerEcosystem::new(cache);
213
214        // Less than 2 characters should return empty
215        let results = ecosystem
216            .complete_package_names("r", Range::default())
217            .await;
218        assert!(results.is_empty());
219
220        // Empty prefix should return empty
221        let results = ecosystem.complete_package_names("", Range::default()).await;
222        assert!(results.is_empty());
223    }
224
225    #[tokio::test]
226    async fn test_complete_package_names_max_length() {
227        let cache = Arc::new(deps_core::HttpCache::new());
228        let ecosystem = BundlerEcosystem::new(cache);
229
230        // Prefix longer than 200 chars should return empty
231        let long_prefix = "a".repeat(201);
232        let results = ecosystem
233            .complete_package_names(&long_prefix, Range::default())
234            .await;
235        assert!(results.is_empty());
236    }
237
238    #[tokio::test]
239    async fn test_lockfile_provider() {
240        let cache = Arc::new(deps_core::HttpCache::new());
241        let ecosystem = BundlerEcosystem::new(cache);
242        assert!(ecosystem.lockfile_provider().is_some());
243    }
244
245    #[tokio::test]
246    async fn test_parse_manifest() {
247        let cache = Arc::new(deps_core::HttpCache::new());
248        let ecosystem = BundlerEcosystem::new(cache);
249
250        let gemfile = r"source 'https://rubygems.org'
251gem 'rails', '~> 7.0'";
252
253        #[cfg(windows)]
254        let path = "C:/test/Gemfile";
255        #[cfg(not(windows))]
256        let path = "/test/Gemfile";
257        let uri = Uri::from_file_path(path).unwrap();
258
259        let result = ecosystem.parse_manifest(gemfile, &uri).await.unwrap();
260        assert_eq!(result.dependencies().len(), 1);
261    }
262}