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, 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    /// Detects completion context for Gradle files at the given position.
55    ///
56    /// Returns `(context_type, value, range)` where `context_type` is
57    /// "version" | "package" | ""; `value` is the already-typed prefix up to the
58    /// cursor; `range` spans the *entire* existing package coordinate (module/`group:artifact`)
59    /// being completed, not just up to the cursor, and is meaningless when
60    /// `context_type` is not "package" (mirrors `MavenEcosystem::detect_xml_context`).
61    ///
62    /// `position.character` is a UTF-16 code unit offset (LSP spec) and is converted to a
63    /// byte offset once via [`deps_core::completion::utf16_to_byte_offset`] before any
64    /// slicing, avoiding panics on multi-byte content preceding the cursor (e.g. an accented
65    /// character in a `groupId`); the returned `range`'s `character` fields are converted
66    /// back to UTF-16 units via [`deps_core::completion::byte_to_utf16_offset`].
67    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
95/// Builds an LSP [`Range`] on `line_idx` from a pair of byte offsets into `line`,
96/// converting each to a UTF-16 code unit offset via
97/// [`deps_core::completion::byte_to_utf16_offset`].
98fn 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
111/// Finds the byte offset (relative to `before_cursor`) where the current inline-table
112/// field starts — right after the last comma that is *not* inside a quoted string.
113///
114/// An inline-table catalog entry like `lib = { module = "...", version = "..." }` puts
115/// multiple `key = "value"` fields on one line; without this, an unscoped `rfind` for
116/// "version"/"module" (and the quote-parity check alongside it) can walk back past a
117/// comma into an *earlier* field and misidentify which field the cursor is actually in
118/// (e.g. treating a cursor inside `module`'s still-open value as "version" context,
119/// because "version" appears earlier on the line and the combined quote count happens
120/// to be odd).
121fn 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
134/// Detects completion context in version catalog files.
135///
136/// `col_idx`/`before_cursor` are byte offsets (see
137/// `GradleEcosystem::detect_completion_context`'s doc comment); the returned `Range`'s
138/// character fields are UTF-16 code unit offsets.
139fn 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    // Scope keyword/quote-parity detection to the current inline-table field (see
147    // `current_field_start`'s doc comment) so an earlier field on the same line can't be
148    // mistaken for the one the cursor is actually in.
149    let field_start = current_field_start(before_cursor);
150    let field = &before_cursor[field_start..];
151
152    // version = "..." or version.ref = "..."
153    if let Some(rel_eq_pos) = field.rfind("version")
154        && let after = &field[rel_eq_pos..]
155        && after.contains('=')
156        // An odd quote count means the cursor sits inside an unclosed string opened by
157        // the LAST quote in `after` — i.e. `rfind` below is genuinely the opening quote.
158        // With an even count (string already closed, or no quote at all before cursor)
159        // the cursor is past this `version = "..."` entirely (e.g. a trailing comment on
160        // the same line), and this is not the right completion context.
161        && 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    // module = "..."
171    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            // Fall back to the cursor position (not end-of-line) when unterminated, so an
180            // unclosed string doesn't swallow unrelated trailing line content into the
181            // replace range (mirrors `MavenEcosystem::detect_xml_context`'s equivalent
182            // no-closing-tag fallback).
183            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
195/// Detects completion context in Kotlin/Groovy DSL files.
196///
197/// `col_idx`/`before_cursor` are byte offsets (see
198/// `GradleEcosystem::detect_completion_context`'s doc comment); the returned `Range`'s
199/// character fields are UTF-16 code unit offsets.
200fn 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            // The package range covers "group" or "group:artifact" — up to a second
231            // colon (start of an already-typed version) if one exists, else the closing
232            // quote. If the string is unterminated on this line, the scan is bounded by
233            // the cursor instead of end-of-line, so it doesn't swallow unrelated trailing
234            // content (mirrors `MavenEcosystem::detect_xml_context`'s no-closing-tag
235            // fallback).
236            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        // version = "|1.0.0"
415        let line = r#"version = "1.0.0""#;
416        // before_cursor = `version = "`, cursor at 11 (right after '"')
417        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        // version = "1.0|.0"
427        let line = r#"version = "1.0.0""#;
428        // value_start = 11, "1.0" = 3 chars, cursor at 14
429        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        // version = "1.0.0|"
439        let line = r#"version = "1.0.0""#;
440        // value_start = 11, "1.0.0" = 5 chars, cursor at 16
441        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        // module = "com.ex|ample:lib"
451        let line = r#"module = "com.example:lib""#;
452        // value_start = 9 + 1 = 10 (after `module = "`), "com.ex" = 6 chars, cursor at 16
453        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        // range replaces the whole quoted value ("com.example:lib"), not just "com.ex"
459        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        // implementation("junit|:junit:4.13.2")
469        let line = r#"implementation("junit:junit:4.13.2")"#;
470        // open_pos=15 ('"'), "junit" = 5 chars, cursor at 21 (after 5 chars)
471        // before_cursor = `implementation("junit`
472        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        // range replaces the whole "group:artifact" coordinate ("junit:junit"),
478        // stopping before the version separator, not just the already-typed "junit"
479        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        // implementation("junit|") — no colon typed yet, string not closed by a version
489        let line = r#"implementation("junit")"#;
490        let col = 21; // right after "junit"
491        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        // module = "café:lib" — 'é' is 2 bytes in UTF-8 but 1 UTF-16 code unit, so byte
505        // and UTF-16 offsets diverge from this point on in the line. Exercises the
506        // top-level UTF-16-to-byte conversion and the byte-to-UTF-16 conversion on the
507        // returned range (regression test for the #232 follow-up: byte offsets were
508        // previously emitted directly as UTF-16 character positions).
509        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); // cursor right after "café" (UTF-16 units)
512
513        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        // Live repro from code review: `lib = { module = "com.exämple:lib", version = "1.0" }`
526        // with the cursor right after the fully-typed module value (byte offset 34, right
527        // before the closing quote). Before the UTF-16 fix, the byte offset (34) was
528        // returned directly as the range's end *character* — but "com.exämple:lib" is only
529        // 15 UTF-16 units (ä is 2 bytes / 1 UTF-16 unit), so the correct end is 33, not 34.
530        // A range ending at 34 would extend one UTF-16 unit past the value, consuming the
531        // closing quote itself when the client applies the edit — corrupting the TOML.
532        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); // cursor right after "lib" (UTF-16 units)
537
538        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        // Range must end at UTF-16 33 (right before the closing quote), not 34 (which
542        // would swallow it).
543        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        // implementation("café:junit") — same multi-byte concern as above, in the
552        // Kotlin/Groovy DSL path.
553        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); // cursor right after "café" (UTF-16 units)
556
557        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        // module = "com.example:lib"|  — cursor placed after the closing quote (e.g. in
569        // trailing content on the same line) must not be treated as still inside the
570        // quoted value.
571        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        // module = "com.example:lib   (no closing quote on the line) — the range must
583        // stop at the cursor, not swallow the rest of the line.
584        let line = r#"module = "com.example:li"#;
585        let col = line.len(); // cursor at end of line, right after "li"
586        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        // lib = { version = "1.0", module = "com.exa|  — cursor is inside the *module*
599        // field's still-open value. An earlier field ("version") appearing before it on
600        // the same line must not be mistaken for the current context: without scoping to
601        // the current inline-table field, `rfind("version")` would walk back past the
602        // comma, and the combined quote count across both fields happens to be odd,
603        // producing a bogus "version" context instead of "package".
604        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        // lib = { module = "com.example:lib", version = "1.0|  — the reverse ordering:
619        // cursor inside the *version* field, with a completed "module" field earlier on
620        // the same line. Confirms the field-scoping fix doesn't over-correct and still
621        // matches "version" correctly here.
622        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        // implementation("junit:junit   (no closing quote/paren on the line) — the range
633        // must stop at the cursor, not swallow the rest of the line.
634        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        // implementation("junit:junit:4.1|3.2")
649        let line = r#"implementation("junit:junit:4.13.2")"#;
650        // second ':' at index 27; version_start=28, "4.1"=3 chars, cursor at 31
651        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        // implementation("junit:junit:|4.13.2")
661        let line = r#"implementation("junit:junit:4.13.2")"#;
662        // second ':' at index 27, cursor at 28 (right after it)
663        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}