Skip to main content

deps_gradle/
ecosystem.rs

1//! Gradle ecosystem implementation for deps-lsp.
2
3use std::any::Any;
4use std::sync::Arc;
5use tower_lsp_server::ls_types::{CompletionItem, Position, Uri};
6
7use deps_core::{
8    Ecosystem, ParseResult as ParseResultTrait, Registry, Result, lsp_helpers::EcosystemFormatter,
9    position_in_range,
10};
11use deps_maven::MavenCentralRegistry;
12
13use crate::formatter::GradleFormatter;
14
15pub struct GradleEcosystem {
16    registry: Arc<MavenCentralRegistry>,
17    formatter: GradleFormatter,
18}
19
20impl GradleEcosystem {
21    pub fn new(cache: Arc<deps_core::HttpCache>) -> Self {
22        Self {
23            registry: Arc::new(MavenCentralRegistry::new(cache)),
24            formatter: GradleFormatter,
25        }
26    }
27
28    async fn complete_package_names(&self, prefix: &str) -> Vec<CompletionItem> {
29        deps_core::completion::complete_package_names_generic(self.registry.as_ref(), prefix, 20)
30            .await
31    }
32
33    async fn complete_versions(&self, package_name: &str, prefix: &str) -> Vec<CompletionItem> {
34        deps_core::completion::complete_versions_generic(
35            self.registry.as_ref(),
36            package_name,
37            prefix,
38            &[],
39        )
40        .await
41    }
42
43    /// Detects completion context for Gradle files at the given position.
44    ///
45    /// Returns ("version" | "package" | "", current_value).
46    fn detect_completion_context<'a>(
47        content: &'a str,
48        position: Position,
49        uri: &Uri,
50    ) -> (&'static str, &'a str) {
51        let path = uri.path().to_string();
52        let lines: Vec<&str> = content.lines().collect();
53        let line_idx = position.line as usize;
54        let col_idx = position.character as usize;
55
56        if line_idx >= lines.len() {
57            return ("", "");
58        }
59
60        let line = lines[line_idx];
61        let before_cursor = &line[..col_idx.min(line.len())];
62
63        if path.ends_with("libs.versions.toml") {
64            detect_catalog_context(before_cursor, line, col_idx)
65        } else if path.ends_with(".gradle.kts") || path.ends_with(".gradle") {
66            detect_dsl_context(before_cursor, line, col_idx)
67        } else {
68            ("", "")
69        }
70    }
71}
72
73/// Detects completion context in version catalog files.
74fn detect_catalog_context<'a>(
75    before_cursor: &str,
76    line: &'a str,
77    col_idx: usize,
78) -> (&'static str, &'a str) {
79    let cursor = col_idx.min(line.len());
80    // version = "..." or version.ref = "..."
81    if let Some(eq_pos) = before_cursor.rfind("version")
82        && let after = &before_cursor[eq_pos..]
83        && after.contains('=')
84        && let Some(quote_start) = after.rfind('"')
85    {
86        let value_start = eq_pos + quote_start + 1;
87        if value_start <= cursor {
88            return ("version", &line[value_start..cursor]);
89        }
90    }
91
92    // module = "..."
93    if let Some(eq_pos) = before_cursor.rfind("module")
94        && let after = &before_cursor[eq_pos..]
95        && after.contains('=')
96        && let Some(quote_start) = after.rfind('"')
97    {
98        let value_start = eq_pos + quote_start + 1;
99        if value_start <= cursor {
100            return ("package", &line[value_start..cursor]);
101        }
102    }
103
104    ("", "")
105}
106
107/// Detects completion context in Kotlin/Groovy DSL files.
108fn detect_dsl_context<'a>(
109    before_cursor: &str,
110    line: &'a str,
111    col_idx: usize,
112) -> (&'static str, &'a str) {
113    let cursor = col_idx.min(line.len());
114    let in_string = before_cursor
115        .chars()
116        .filter(|&c| c == '"' || c == '\'')
117        .count()
118        % 2
119        == 1;
120    if !in_string {
121        return ("", "");
122    }
123
124    let colon_count = before_cursor.chars().filter(|&c| c == ':').count();
125    let quote_char = if before_cursor.contains('"') {
126        '"'
127    } else {
128        '\''
129    };
130
131    let Some(open_pos) = before_cursor.rfind(quote_char) else {
132        return ("", "");
133    };
134
135    match colon_count {
136        0 | 1 => ("package", &line[open_pos + 1..cursor]),
137        _ => {
138            let version_start = before_cursor
139                .char_indices()
140                .filter(|(_, c)| *c == ':')
141                .nth(1)
142                .map(|(i, _)| i + 1)
143                .unwrap_or(before_cursor.len());
144            ("version", &line[version_start..cursor])
145        }
146    }
147}
148
149impl deps_core::ecosystem::private::Sealed for GradleEcosystem {}
150
151impl Ecosystem for GradleEcosystem {
152    fn id(&self) -> &'static str {
153        "gradle"
154    }
155
156    fn display_name(&self) -> &'static str {
157        "Gradle (JVM)"
158    }
159
160    fn manifest_filenames(&self) -> &[&'static str] {
161        &[
162            "libs.versions.toml",
163            "build.gradle.kts",
164            "build.gradle",
165            "settings.gradle.kts",
166            "settings.gradle",
167        ]
168    }
169
170    fn lockfile_filenames(&self) -> &[&'static str] {
171        &[]
172    }
173
174    fn parse_manifest<'a>(
175        &'a self,
176        content: &'a str,
177        uri: &'a Uri,
178    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
179        Box::pin(async move {
180            let result =
181                crate::parser::parse_gradle(content, uri).map_err(deps_core::DepsError::from)?;
182            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
183        })
184    }
185
186    fn registry(&self) -> Arc<dyn Registry> {
187        self.registry.clone() as Arc<dyn Registry>
188    }
189
190    fn formatter(&self) -> &dyn EcosystemFormatter {
191        &self.formatter
192    }
193
194    fn generate_completions<'a>(
195        &'a self,
196        parse_result: &'a dyn ParseResultTrait,
197        position: Position,
198        content: &'a str,
199    ) -> deps_core::ecosystem::BoxFuture<'a, Vec<CompletionItem>> {
200        Box::pin(async move {
201            let uri = parse_result.uri();
202            let (ctx_type, value) = Self::detect_completion_context(content, position, uri);
203
204            match ctx_type {
205                "version" => {
206                    let dep = parse_result.dependencies().into_iter().find(|d| {
207                        d.version_range()
208                            .is_some_and(|r| position_in_range(position, r))
209                            || d.name_range().start.line == position.line
210                    });
211                    if let Some(dep) = dep {
212                        self.complete_versions(dep.name(), value).await
213                    } else {
214                        vec![]
215                    }
216                }
217                "package" => self.complete_package_names(value).await,
218                _ => vec![],
219            }
220        })
221    }
222
223    fn as_any(&self) -> &dyn Any {
224        self
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn make_cache() -> Arc<deps_core::HttpCache> {
233        Arc::new(deps_core::HttpCache::new())
234    }
235
236    #[test]
237    fn test_ecosystem_id() {
238        let eco = GradleEcosystem::new(make_cache());
239        assert_eq!(eco.id(), "gradle");
240    }
241
242    #[test]
243    fn test_ecosystem_display_name() {
244        let eco = GradleEcosystem::new(make_cache());
245        assert_eq!(eco.display_name(), "Gradle (JVM)");
246    }
247
248    #[test]
249    fn test_manifest_filenames() {
250        let eco = GradleEcosystem::new(make_cache());
251        assert!(eco.manifest_filenames().contains(&"libs.versions.toml"));
252        assert!(eco.manifest_filenames().contains(&"build.gradle.kts"));
253        assert!(eco.manifest_filenames().contains(&"build.gradle"));
254        assert!(eco.manifest_filenames().contains(&"settings.gradle.kts"));
255        assert!(eco.manifest_filenames().contains(&"settings.gradle"));
256    }
257
258    #[test]
259    fn test_lockfile_filenames_empty() {
260        let eco = GradleEcosystem::new(make_cache());
261        assert!(eco.lockfile_filenames().is_empty());
262    }
263
264    #[test]
265    fn test_lockfile_provider_none() {
266        let eco = GradleEcosystem::new(make_cache());
267        assert!(eco.lockfile_provider().is_none());
268    }
269
270    #[test]
271    fn test_as_any() {
272        let eco = GradleEcosystem::new(make_cache());
273        assert!(eco.as_any().is::<GradleEcosystem>());
274    }
275
276    #[tokio::test]
277    async fn test_complete_package_names_short_prefix() {
278        let eco = GradleEcosystem::new(make_cache());
279        assert!(eco.complete_package_names("a").await.is_empty());
280        assert!(eco.complete_package_names("").await.is_empty());
281    }
282
283    #[tokio::test]
284    async fn test_parse_manifest_kts() {
285        let eco = GradleEcosystem::new(make_cache());
286        let content = "dependencies {\n    implementation(\"junit:junit:4.13.2\")\n}\n";
287        let uri = Uri::from_file_path("/project/build.gradle.kts").unwrap();
288        let result = eco.parse_manifest(content, &uri).await.unwrap();
289        assert_eq!(result.dependencies().len(), 1);
290    }
291
292    #[test]
293    fn test_detect_catalog_context_version_cursor_at_start() {
294        // version = "|1.0.0"
295        let line = r#"version = "1.0.0""#;
296        // before_cursor = `version = "`, cursor at 11 (right after '"')
297        let col = 11;
298        let before = &line[..col];
299        let (t, v) = detect_catalog_context(before, line, col);
300        assert_eq!(t, "version");
301        assert_eq!(v, "");
302    }
303
304    #[test]
305    fn test_detect_catalog_context_version_cursor_mid() {
306        // version = "1.0|.0"
307        let line = r#"version = "1.0.0""#;
308        // value_start = 11, "1.0" = 3 chars, cursor at 14
309        let col = 14;
310        let before = &line[..col];
311        let (t, v) = detect_catalog_context(before, line, col);
312        assert_eq!(t, "version");
313        assert_eq!(v, "1.0");
314    }
315
316    #[test]
317    fn test_detect_catalog_context_version_cursor_at_end() {
318        // version = "1.0.0|"
319        let line = r#"version = "1.0.0""#;
320        // value_start = 11, "1.0.0" = 5 chars, cursor at 16
321        let col = 16;
322        let before = &line[..col];
323        let (t, v) = detect_catalog_context(before, line, col);
324        assert_eq!(t, "version");
325        assert_eq!(v, "1.0.0");
326    }
327
328    #[test]
329    fn test_detect_catalog_context_module_prefix() {
330        // module = "com.ex|ample:lib"
331        let line = r#"module = "com.example:lib""#;
332        // value_start = 9 + 1 = 10 (after `module = "`), "com.ex" = 6 chars, cursor at 16
333        let col = 16;
334        let before = &line[..col];
335        let (t, v) = detect_catalog_context(before, line, col);
336        assert_eq!(t, "package");
337        assert_eq!(v, "com.ex");
338    }
339
340    #[test]
341    fn test_detect_dsl_context_package_cursor_mid() {
342        // implementation("junit|:junit:4.13.2")
343        let line = r#"implementation("junit:junit:4.13.2")"#;
344        // open_pos=15 ('"'), "junit" = 5 chars, cursor at 21 (after 5 chars)
345        // before_cursor = `implementation("junit`
346        let col = 21;
347        let before = &line[..col];
348        let (t, v) = detect_dsl_context(before, line, col);
349        assert_eq!(t, "package");
350        assert_eq!(v, "junit");
351    }
352
353    #[test]
354    fn test_detect_dsl_context_version_cursor_mid() {
355        // implementation("junit:junit:4.1|3.2")
356        let line = r#"implementation("junit:junit:4.13.2")"#;
357        // second ':' at index 27; version_start=28, "4.1"=3 chars, cursor at 31
358        let col = 31;
359        let before = &line[..col];
360        let (t, v) = detect_dsl_context(before, line, col);
361        assert_eq!(t, "version");
362        assert_eq!(v, "4.1");
363    }
364
365    #[test]
366    fn test_detect_dsl_context_version_cursor_at_start() {
367        // implementation("junit:junit:|4.13.2")
368        let line = r#"implementation("junit:junit:4.13.2")"#;
369        // second ':' at index 27, cursor at 28 (right after it)
370        let col = 28;
371        let before = &line[..col];
372        let (t, v) = detect_dsl_context(before, line, col);
373        assert_eq!(t, "version");
374        assert_eq!(v, "");
375    }
376
377    #[tokio::test]
378    async fn test_parse_manifest_groovy() {
379        let eco = GradleEcosystem::new(make_cache());
380        let content = "dependencies {\n    implementation 'junit:junit:4.13.2'\n}\n";
381        let uri = Uri::from_file_path("/project/build.gradle").unwrap();
382        let result = eco.parse_manifest(content, &uri).await.unwrap();
383        assert_eq!(result.dependencies().len(), 1);
384    }
385}