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)
65        } else if path.ends_with(".gradle.kts") || path.ends_with(".gradle") {
66            detect_dsl_context(before_cursor, line)
67        } else {
68            ("", "")
69        }
70    }
71}
72
73/// Detects completion context in version catalog files.
74fn detect_catalog_context<'a>(before_cursor: &str, line: &'a str) -> (&'static str, &'a str) {
75    // version = "..." or version.ref = "..."
76    if let Some(eq_pos) = before_cursor.rfind("version")
77        && let after = &before_cursor[eq_pos..]
78        && after.contains('=')
79        && let Some(quote_start) = after.rfind('"')
80    {
81        let value_start = eq_pos + quote_start + 1;
82        if value_start <= line.len() {
83            let quote_end = line[value_start..]
84                .find('"')
85                .map_or(line.len(), |i| value_start + i);
86            return ("version", &line[value_start..quote_end]);
87        }
88    }
89
90    // module = "..."
91    if let Some(eq_pos) = before_cursor.rfind("module")
92        && let after = &before_cursor[eq_pos..]
93        && after.contains('=')
94        && let Some(quote_start) = after.rfind('"')
95    {
96        let value_start = eq_pos + quote_start + 1;
97        if value_start <= line.len() {
98            let quote_end = line[value_start..]
99                .find('"')
100                .map_or(line.len(), |i| value_start + i);
101            return ("package", &line[value_start..quote_end]);
102        }
103    }
104
105    ("", "")
106}
107
108/// Detects completion context in Kotlin/Groovy DSL files.
109fn detect_dsl_context<'a>(before_cursor: &str, line: &'a str) -> (&'static str, &'a str) {
110    let in_string = before_cursor
111        .chars()
112        .filter(|&c| c == '"' || c == '\'')
113        .count()
114        % 2
115        == 1;
116    if !in_string {
117        return ("", "");
118    }
119
120    let colon_count = before_cursor.chars().filter(|&c| c == ':').count();
121    let quote_char = if before_cursor.contains('"') {
122        '"'
123    } else {
124        '\''
125    };
126
127    let Some(open_pos) = before_cursor.rfind(quote_char) else {
128        return ("", "");
129    };
130
131    match colon_count {
132        0 | 1 => {
133            let close = line[open_pos + 1..]
134                .find(['"', '\''])
135                .map_or(line.len(), |i| open_pos + 1 + i);
136            ("package", &line[open_pos + 1..close])
137        }
138        _ => {
139            let version_start = before_cursor
140                .char_indices()
141                .filter(|(_, c)| *c == ':')
142                .nth(1)
143                .map(|(i, _)| i + 1)
144                .unwrap_or(before_cursor.len());
145            let close = line[version_start..]
146                .find(['"', '\''])
147                .map_or(line.len(), |i| version_start + i);
148            ("version", &line[version_start..close])
149        }
150    }
151}
152
153impl deps_core::ecosystem::private::Sealed for GradleEcosystem {}
154
155impl Ecosystem for GradleEcosystem {
156    fn id(&self) -> &'static str {
157        "gradle"
158    }
159
160    fn display_name(&self) -> &'static str {
161        "Gradle (JVM)"
162    }
163
164    fn manifest_filenames(&self) -> &[&'static str] {
165        &[
166            "libs.versions.toml",
167            "build.gradle.kts",
168            "build.gradle",
169            "settings.gradle.kts",
170            "settings.gradle",
171        ]
172    }
173
174    fn lockfile_filenames(&self) -> &[&'static str] {
175        &[]
176    }
177
178    fn parse_manifest<'a>(
179        &'a self,
180        content: &'a str,
181        uri: &'a Uri,
182    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
183        Box::pin(async move {
184            let result =
185                crate::parser::parse_gradle(content, uri).map_err(deps_core::DepsError::from)?;
186            Ok(Box::new(result) as Box<dyn ParseResultTrait>)
187        })
188    }
189
190    fn registry(&self) -> Arc<dyn Registry> {
191        self.registry.clone() as Arc<dyn Registry>
192    }
193
194    fn formatter(&self) -> &dyn EcosystemFormatter {
195        &self.formatter
196    }
197
198    fn generate_completions<'a>(
199        &'a self,
200        parse_result: &'a dyn ParseResultTrait,
201        position: Position,
202        content: &'a str,
203    ) -> deps_core::ecosystem::BoxFuture<'a, Vec<CompletionItem>> {
204        Box::pin(async move {
205            let uri = parse_result.uri();
206            let (ctx_type, value) = Self::detect_completion_context(content, position, uri);
207
208            match ctx_type {
209                "version" => {
210                    let dep = parse_result.dependencies().into_iter().find(|d| {
211                        d.version_range()
212                            .is_some_and(|r| position_in_range(position, r))
213                            || d.name_range().start.line == position.line
214                    });
215                    if let Some(dep) = dep {
216                        self.complete_versions(dep.name(), value).await
217                    } else {
218                        vec![]
219                    }
220                }
221                "package" => self.complete_package_names(value).await,
222                _ => vec![],
223            }
224        })
225    }
226
227    fn as_any(&self) -> &dyn Any {
228        self
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    fn make_cache() -> Arc<deps_core::HttpCache> {
237        Arc::new(deps_core::HttpCache::new())
238    }
239
240    #[test]
241    fn test_ecosystem_id() {
242        let eco = GradleEcosystem::new(make_cache());
243        assert_eq!(eco.id(), "gradle");
244    }
245
246    #[test]
247    fn test_ecosystem_display_name() {
248        let eco = GradleEcosystem::new(make_cache());
249        assert_eq!(eco.display_name(), "Gradle (JVM)");
250    }
251
252    #[test]
253    fn test_manifest_filenames() {
254        let eco = GradleEcosystem::new(make_cache());
255        assert!(eco.manifest_filenames().contains(&"libs.versions.toml"));
256        assert!(eco.manifest_filenames().contains(&"build.gradle.kts"));
257        assert!(eco.manifest_filenames().contains(&"build.gradle"));
258        assert!(eco.manifest_filenames().contains(&"settings.gradle.kts"));
259        assert!(eco.manifest_filenames().contains(&"settings.gradle"));
260    }
261
262    #[test]
263    fn test_lockfile_filenames_empty() {
264        let eco = GradleEcosystem::new(make_cache());
265        assert!(eco.lockfile_filenames().is_empty());
266    }
267
268    #[test]
269    fn test_lockfile_provider_none() {
270        let eco = GradleEcosystem::new(make_cache());
271        assert!(eco.lockfile_provider().is_none());
272    }
273
274    #[test]
275    fn test_as_any() {
276        let eco = GradleEcosystem::new(make_cache());
277        assert!(eco.as_any().is::<GradleEcosystem>());
278    }
279
280    #[tokio::test]
281    async fn test_complete_package_names_short_prefix() {
282        let eco = GradleEcosystem::new(make_cache());
283        assert!(eco.complete_package_names("a").await.is_empty());
284        assert!(eco.complete_package_names("").await.is_empty());
285    }
286
287    #[tokio::test]
288    async fn test_parse_manifest_kts() {
289        let eco = GradleEcosystem::new(make_cache());
290        let content = "dependencies {\n    implementation(\"junit:junit:4.13.2\")\n}\n";
291        let uri = Uri::from_file_path("/project/build.gradle.kts").unwrap();
292        let result = eco.parse_manifest(content, &uri).await.unwrap();
293        assert_eq!(result.dependencies().len(), 1);
294    }
295
296    #[tokio::test]
297    async fn test_parse_manifest_groovy() {
298        let eco = GradleEcosystem::new(make_cache());
299        let content = "dependencies {\n    implementation 'junit:junit:4.13.2'\n}\n";
300        let uri = Uri::from_file_path("/project/build.gradle").unwrap();
301        let result = eco.parse_manifest(content, &uri).await.unwrap();
302        assert_eq!(result.dependencies().len(), 1);
303    }
304}