1use 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, 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, range: Range) -> Vec<CompletionItem> {
29 deps_core::completion::complete_package_names_generic(
30 self.registry.as_ref(),
31 prefix,
32 20,
33 range,
34 )
35 .await
36 }
37
38 async fn complete_versions(
39 &self,
40 package_name: &deps_core::PackageName,
41 prefix: &str,
42 freshness: deps_core::FreshnessSettings,
43 ) -> Vec<CompletionItem> {
44 deps_core::completion::complete_versions_generic(
45 self.registry.as_ref(),
46 package_name,
47 prefix,
48 &[],
49 freshness,
50 )
51 .await
52 }
53
54 fn detect_completion_context<'a>(
68 content: &'a str,
69 position: Position,
70 uri: &Uri,
71 ) -> (&'static str, &'a str, Range) {
72 let path = uri.path().to_string();
73 let lines: Vec<&str> = content.lines().collect();
74 let line_idx = position.line as usize;
75
76 if line_idx >= lines.len() {
77 return ("", "", Range::default());
78 }
79
80 let line = lines[line_idx];
81 let col_idx = deps_core::completion::utf16_to_byte_offset(line, position.character)
82 .unwrap_or(line.len());
83 let before_cursor = &line[..col_idx];
84
85 if path.ends_with("libs.versions.toml") {
86 detect_catalog_context(before_cursor, line, col_idx, position.line)
87 } else if path.ends_with(".gradle.kts") || path.ends_with(".gradle") {
88 detect_dsl_context(before_cursor, line, col_idx, position.line)
89 } else {
90 ("", "", Range::default())
91 }
92 }
93}
94
95fn byte_range(line: &str, line_idx: u32, start_byte: usize, end_byte: usize) -> Range {
99 Range::new(
100 Position::new(
101 line_idx,
102 deps_core::completion::byte_to_utf16_offset(line, start_byte),
103 ),
104 Position::new(
105 line_idx,
106 deps_core::completion::byte_to_utf16_offset(line, end_byte),
107 ),
108 )
109}
110
111fn current_field_start(before_cursor: &str) -> usize {
122 let mut in_string = false;
123 let mut field_start = 0;
124 for (i, c) in before_cursor.char_indices() {
125 match c {
126 '"' => in_string = !in_string,
127 ',' if !in_string => field_start = i + 1,
128 _ => {}
129 }
130 }
131 field_start
132}
133
134fn detect_catalog_context<'a>(
140 before_cursor: &str,
141 line: &'a str,
142 col_idx: usize,
143 line_idx: u32,
144) -> (&'static str, &'a str, Range) {
145 let cursor = col_idx.min(line.len());
146 let field_start = current_field_start(before_cursor);
150 let field = &before_cursor[field_start..];
151
152 if let Some(rel_eq_pos) = field.rfind("version")
154 && let after = &field[rel_eq_pos..]
155 && after.contains('=')
156 && after.chars().filter(|&c| c == '"').count() % 2 == 1
162 && let Some(quote_start) = after.rfind('"')
163 {
164 let value_start = field_start + rel_eq_pos + quote_start + 1;
165 if value_start <= cursor {
166 return ("version", &line[value_start..cursor], Range::default());
167 }
168 }
169
170 if let Some(rel_eq_pos) = field.rfind("module")
172 && let after = &field[rel_eq_pos..]
173 && after.contains('=')
174 && after.chars().filter(|&c| c == '"').count() % 2 == 1
175 && let Some(quote_start) = after.rfind('"')
176 {
177 let value_start = field_start + rel_eq_pos + quote_start + 1;
178 if value_start <= cursor {
179 let value_end = line[value_start..]
184 .find('"')
185 .map_or(cursor, |rel| value_start + rel)
186 .max(cursor);
187 let range = byte_range(line, line_idx, value_start, value_end);
188 return ("package", &line[value_start..cursor], range);
189 }
190 }
191
192 ("", "", Range::default())
193}
194
195fn detect_dsl_context<'a>(
201 before_cursor: &str,
202 line: &'a str,
203 col_idx: usize,
204 line_idx: u32,
205) -> (&'static str, &'a str, Range) {
206 let cursor = col_idx.min(line.len());
207 let in_string = before_cursor
208 .chars()
209 .filter(|&c| c == '"' || c == '\'')
210 .count()
211 % 2
212 == 1;
213 if !in_string {
214 return ("", "", Range::default());
215 }
216
217 let colon_count = before_cursor.chars().filter(|&c| c == ':').count();
218 let quote_char = if before_cursor.contains('"') {
219 '"'
220 } else {
221 '\''
222 };
223
224 let Some(open_pos) = before_cursor.rfind(quote_char) else {
225 return ("", "", Range::default());
226 };
227
228 match colon_count {
229 0 | 1 => {
230 let rest = &line[open_pos + 1..];
237 let closing_quote_rel = rest.find(quote_char);
238 let scan_limit_rel = closing_quote_rel.unwrap_or(cursor - (open_pos + 1));
239 let end_rel = rest[..scan_limit_rel]
240 .char_indices()
241 .filter(|&(_, c)| c == ':')
242 .nth(1)
243 .map_or(scan_limit_rel, |(i, _)| i);
244 let value_end = (open_pos + 1 + end_rel).max(cursor);
245 let range = byte_range(line, line_idx, open_pos + 1, value_end);
246 ("package", &line[open_pos + 1..cursor], range)
247 }
248 _ => {
249 let version_start = before_cursor
250 .char_indices()
251 .filter(|(_, c)| *c == ':')
252 .nth(1)
253 .map(|(i, _)| i + 1)
254 .unwrap_or(before_cursor.len());
255 ("version", &line[version_start..cursor], Range::default())
256 }
257 }
258}
259
260impl deps_core::ecosystem::private::Sealed for GradleEcosystem {}
261
262impl Ecosystem for GradleEcosystem {
263 fn id(&self) -> &'static str {
264 "gradle"
265 }
266
267 fn display_name(&self) -> &'static str {
268 "Gradle (JVM)"
269 }
270
271 fn manifest_filenames(&self) -> &[&'static str] {
272 &[
273 "libs.versions.toml",
274 "build.gradle.kts",
275 "build.gradle",
276 "settings.gradle.kts",
277 "settings.gradle",
278 ]
279 }
280
281 fn lockfile_filenames(&self) -> &[&'static str] {
282 &[]
283 }
284
285 fn parse_manifest<'a>(
286 &'a self,
287 content: &'a str,
288 uri: &'a Uri,
289 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Box<dyn ParseResultTrait>>> {
290 Box::pin(async move {
291 let result = crate::parser::parse_gradle(content, uri)?;
292 Ok(Box::new(result) as Box<dyn ParseResultTrait>)
293 })
294 }
295
296 fn registry(&self) -> Arc<dyn Registry> {
297 self.registry.clone() as Arc<dyn Registry>
298 }
299
300 fn formatter(&self) -> &dyn EcosystemFormatter {
301 &self.formatter
302 }
303
304 fn generate_completions<'a>(
305 &'a self,
306 parse_result: &'a dyn ParseResultTrait,
307 position: Position,
308 content: &'a str,
309 freshness: deps_core::FreshnessSettings,
310 ) -> deps_core::ecosystem::BoxFuture<'a, Completions> {
311 Box::pin(async move {
312 let uri = parse_result.uri();
313 let (ctx_type, value, range) = Self::detect_completion_context(content, position, uri);
314
315 match ctx_type {
316 "version" => {
317 let dep = parse_result.dependencies().into_iter().find(|d| {
318 d.version_range()
319 .is_some_and(|r| position_in_range(position, r))
320 || d.name_range().start.line == position.line
321 });
322 if let Some(dep) = dep {
323 self.complete_versions(dep.name(), value, freshness).await
324 } else {
325 vec![]
326 }
327 }
328 "package" => self.complete_package_names(value, range).await,
329 _ => vec![],
330 }
331 .into()
332 })
333 }
334
335 fn as_any(&self) -> &dyn Any {
336 self
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn make_cache() -> Arc<deps_core::HttpCache> {
345 Arc::new(deps_core::HttpCache::new())
346 }
347
348 #[test]
349 fn test_ecosystem_id() {
350 let eco = GradleEcosystem::new(make_cache());
351 assert_eq!(eco.id(), "gradle");
352 }
353
354 #[test]
355 fn test_ecosystem_display_name() {
356 let eco = GradleEcosystem::new(make_cache());
357 assert_eq!(eco.display_name(), "Gradle (JVM)");
358 }
359
360 #[test]
361 fn test_manifest_filenames() {
362 let eco = GradleEcosystem::new(make_cache());
363 assert!(eco.manifest_filenames().contains(&"libs.versions.toml"));
364 assert!(eco.manifest_filenames().contains(&"build.gradle.kts"));
365 assert!(eco.manifest_filenames().contains(&"build.gradle"));
366 assert!(eco.manifest_filenames().contains(&"settings.gradle.kts"));
367 assert!(eco.manifest_filenames().contains(&"settings.gradle"));
368 }
369
370 #[test]
371 fn test_lockfile_filenames_empty() {
372 let eco = GradleEcosystem::new(make_cache());
373 assert!(eco.lockfile_filenames().is_empty());
374 }
375
376 #[test]
377 fn test_lockfile_provider_none() {
378 let eco = GradleEcosystem::new(make_cache());
379 assert!(eco.lockfile_provider().is_none());
380 }
381
382 #[test]
383 fn test_as_any() {
384 let eco = GradleEcosystem::new(make_cache());
385 assert!(eco.as_any().is::<GradleEcosystem>());
386 }
387
388 #[tokio::test]
389 async fn test_complete_package_names_short_prefix() {
390 let eco = GradleEcosystem::new(make_cache());
391 assert!(
392 eco.complete_package_names("a", Range::default())
393 .await
394 .is_empty()
395 );
396 assert!(
397 eco.complete_package_names("", Range::default())
398 .await
399 .is_empty()
400 );
401 }
402
403 #[tokio::test]
404 async fn test_parse_manifest_kts() {
405 let eco = GradleEcosystem::new(make_cache());
406 let content = "dependencies {\n implementation(\"junit:junit:4.13.2\")\n}\n";
407 let uri = deps_core::test_util::test_uri("/project/build.gradle.kts");
408 let result = eco.parse_manifest(content, &uri).await.unwrap();
409 assert_eq!(result.dependencies().len(), 1);
410 }
411
412 #[test]
413 fn test_detect_catalog_context_version_cursor_at_start() {
414 let line = r#"version = "1.0.0""#;
416 let col = 11;
418 let before = &line[..col];
419 let (t, v, _) = detect_catalog_context(before, line, col, 0);
420 assert_eq!(t, "version");
421 assert_eq!(v, "");
422 }
423
424 #[test]
425 fn test_detect_catalog_context_version_cursor_mid() {
426 let line = r#"version = "1.0.0""#;
428 let col = 14;
430 let before = &line[..col];
431 let (t, v, _) = detect_catalog_context(before, line, col, 0);
432 assert_eq!(t, "version");
433 assert_eq!(v, "1.0");
434 }
435
436 #[test]
437 fn test_detect_catalog_context_version_cursor_at_end() {
438 let line = r#"version = "1.0.0""#;
440 let col = 16;
442 let before = &line[..col];
443 let (t, v, _) = detect_catalog_context(before, line, col, 0);
444 assert_eq!(t, "version");
445 assert_eq!(v, "1.0.0");
446 }
447
448 #[test]
449 fn test_detect_catalog_context_module_prefix() {
450 let line = r#"module = "com.example:lib""#;
452 let col = 16;
454 let before = &line[..col];
455 let (t, v, range) = detect_catalog_context(before, line, col, 0);
456 assert_eq!(t, "package");
457 assert_eq!(v, "com.ex");
458 assert_eq!(
460 range,
461 Range::new(Position::new(0, 10), Position::new(0, 25))
462 );
463 assert_eq!(&line[10..25], "com.example:lib");
464 }
465
466 #[test]
467 fn test_detect_dsl_context_package_cursor_mid() {
468 let line = r#"implementation("junit:junit:4.13.2")"#;
470 let col = 21;
473 let before = &line[..col];
474 let (t, v, range) = detect_dsl_context(before, line, col, 0);
475 assert_eq!(t, "package");
476 assert_eq!(v, "junit");
477 assert_eq!(
480 range,
481 Range::new(Position::new(0, 16), Position::new(0, 27))
482 );
483 assert_eq!(&line[16..27], "junit:junit");
484 }
485
486 #[test]
487 fn test_detect_dsl_context_package_no_version_yet() {
488 let line = r#"implementation("junit")"#;
490 let col = 21; let before = &line[..col];
492 let (t, v, range) = detect_dsl_context(before, line, col, 0);
493 assert_eq!(t, "package");
494 assert_eq!(v, "junit");
495 assert_eq!(
496 range,
497 Range::new(Position::new(0, 16), Position::new(0, 21))
498 );
499 assert_eq!(&line[16..21], "junit");
500 }
501
502 #[test]
503 fn test_detect_completion_context_catalog_multibyte_module_value() {
504 let content = "module = \"café:lib\"\n";
510 let uri = deps_core::test_util::test_uri("/test/libs.versions.toml");
511 let position = Position::new(0, 14); let (t, v, range) = GradleEcosystem::detect_completion_context(content, position, &uri);
514 assert_eq!(t, "package");
515 assert_eq!(v, "café");
516 assert_eq!(
517 range,
518 Range::new(Position::new(0, 10), Position::new(0, 18))
519 );
520 }
521
522 #[test]
523 fn test_detect_completion_context_catalog_inline_table_multibyte_does_not_consume_closing_quote()
524 {
525 let content = r#"lib = { module = "com.exämple:lib", version = "1.0" }"#;
533 assert_eq!(&content[18..34], "com.exämple:lib");
534 assert_eq!(content.as_bytes()[34], b'"');
535 let uri = deps_core::test_util::test_uri("/test/libs.versions.toml");
536 let position = Position::new(0, 33); let (t, v, range) = GradleEcosystem::detect_completion_context(content, position, &uri);
539 assert_eq!(t, "package");
540 assert_eq!(v, "com.exämple:lib");
541 assert_eq!(
544 range,
545 Range::new(Position::new(0, 18), Position::new(0, 33))
546 );
547 }
548
549 #[test]
550 fn test_detect_completion_context_dsl_multibyte_package_value() {
551 let content = "implementation(\"café:junit\")\n";
554 let uri = deps_core::test_util::test_uri("/project/build.gradle.kts");
555 let position = Position::new(0, 20); let (t, v, range) = GradleEcosystem::detect_completion_context(content, position, &uri);
558 assert_eq!(t, "package");
559 assert_eq!(v, "café");
560 assert_eq!(
561 range,
562 Range::new(Position::new(0, 16), Position::new(0, 26))
563 );
564 }
565
566 #[test]
567 fn test_detect_catalog_context_cursor_past_closing_quote_not_matched() {
568 let line = r#"module = "com.example:lib" # trailing"#;
572 let col = line.len();
573 let before = &line[..col];
574 let (t, v, range) = detect_catalog_context(before, line, col, 0);
575 assert_eq!(t, "");
576 assert_eq!(v, "");
577 assert_eq!(range, Range::default());
578 }
579
580 #[test]
581 fn test_detect_catalog_context_module_unterminated_falls_back_to_cursor() {
582 let line = r#"module = "com.example:li"#;
585 let col = line.len(); let before = &line[..col];
587 let (t, v, range) = detect_catalog_context(before, line, col, 0);
588 assert_eq!(t, "package");
589 assert_eq!(v, "com.example:li");
590 assert_eq!(
591 range,
592 Range::new(Position::new(0, 10), Position::new(0, col as u32))
593 );
594 }
595
596 #[test]
597 fn test_detect_catalog_context_inline_table_does_not_leak_across_fields() {
598 let line = r#"lib = { version = "1.0", module = "com.exa"#;
605 let col = line.len();
606 let before = &line[..col];
607 let (t, v, range) = detect_catalog_context(before, line, col, 0);
608 assert_eq!(t, "package");
609 assert_eq!(v, "com.exa");
610 assert_eq!(
611 range,
612 Range::new(Position::new(0, 35), Position::new(0, col as u32))
613 );
614 }
615
616 #[test]
617 fn test_detect_catalog_context_inline_table_version_field_after_module() {
618 let line = r#"lib = { module = "com.example:lib", version = "1.0"#;
623 let col = line.len();
624 let before = &line[..col];
625 let (t, v, _range) = detect_catalog_context(before, line, col, 0);
626 assert_eq!(t, "version");
627 assert_eq!(v, "1.0");
628 }
629
630 #[test]
631 fn test_detect_dsl_context_unterminated_falls_back_to_cursor() {
632 let line = r#"implementation("junit:junit"#;
635 let col = line.len();
636 let before = &line[..col];
637 let (t, v, range) = detect_dsl_context(before, line, col, 0);
638 assert_eq!(t, "package");
639 assert_eq!(v, "junit:junit");
640 assert_eq!(
641 range,
642 Range::new(Position::new(0, 16), Position::new(0, col as u32))
643 );
644 }
645
646 #[test]
647 fn test_detect_dsl_context_version_cursor_mid() {
648 let line = r#"implementation("junit:junit:4.13.2")"#;
650 let col = 31;
652 let before = &line[..col];
653 let (t, v, _) = detect_dsl_context(before, line, col, 0);
654 assert_eq!(t, "version");
655 assert_eq!(v, "4.1");
656 }
657
658 #[test]
659 fn test_detect_dsl_context_version_cursor_at_start() {
660 let line = r#"implementation("junit:junit:4.13.2")"#;
662 let col = 28;
664 let before = &line[..col];
665 let (t, v, _) = detect_dsl_context(before, line, col, 0);
666 assert_eq!(t, "version");
667 assert_eq!(v, "");
668 }
669
670 #[tokio::test]
671 async fn test_parse_manifest_groovy() {
672 let eco = GradleEcosystem::new(make_cache());
673 let content = "dependencies {\n implementation 'junit:junit:4.13.2'\n}\n";
674 let uri = deps_core::test_util::test_uri("/project/build.gradle");
675 let result = eco.parse_manifest(content, &uri).await.unwrap();
676 assert_eq!(result.dependencies().len(), 1);
677 }
678}