Skip to main content

deps_gradle/parser/
catalog.rs

1//! Parser for Gradle Version Catalog (gradle/libs.versions.toml).
2//!
3//! Handles \[versions\], \[libraries\] sections with position tracking via toml-span.
4
5use crate::error::{GradleError, Result};
6use crate::parser::{GradleParseResult, LineOffsetTable};
7use crate::types::GradleDependency;
8use std::collections::HashMap;
9use toml_span::value::{Table, Value};
10use tower_lsp_server::ls_types::{Range, Uri};
11
12pub fn parse_version_catalog(content: &str, uri: &Uri) -> Result<GradleParseResult> {
13    let doc = toml_span::parse(content).map_err(|e| GradleError::ParseError {
14        message: e.to_string(),
15    })?;
16
17    let line_table = LineOffsetTable::new(content);
18    let mut version_refs: HashMap<String, String> = HashMap::new();
19
20    // Collect [versions] section: key -> version string
21    if let Some(versions_table) = doc.as_table().and_then(|t| get_table_val(t, "versions"))
22        && let Some(t) = versions_table.as_table()
23    {
24        for (key, item) in t {
25            if let Some(ver_str) = item.as_str() {
26                version_refs.insert(key.name.to_string(), ver_str.to_string());
27            }
28        }
29    }
30
31    let mut dependencies = Vec::new();
32
33    let Some(libs_table) = doc
34        .as_table()
35        .and_then(|t| get_table_val(t, "libraries"))
36        .and_then(|v| v.as_table())
37    else {
38        return Ok(GradleParseResult {
39            dependencies,
40            uri: uri.clone(),
41        });
42    };
43
44    for item in libs_table.values() {
45        let Some(dep) = parse_library_entry(item, content, &line_table, &version_refs) else {
46            continue;
47        };
48        dependencies.push(dep);
49    }
50
51    Ok(GradleParseResult {
52        dependencies,
53        uri: uri.clone(),
54    })
55}
56
57fn get_table_val<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Value<'a>> {
58    table.get(key)
59}
60
61fn parse_library_entry(
62    item: &Value<'_>,
63    content: &str,
64    line_table: &LineOffsetTable,
65    version_refs: &HashMap<String, String>,
66) -> Option<GradleDependency> {
67    let table = item.as_table()?;
68    let (group_id, artifact_id, name, name_range) =
69        extract_coordinates(table, content, line_table)?;
70    let (version_req, version_range) = extract_version(table, content, line_table, version_refs);
71
72    Some(GradleDependency {
73        group_id,
74        artifact_id,
75        name,
76        name_range,
77        version_req,
78        version_range,
79        configuration: String::new(),
80    })
81}
82
83fn extract_coordinates<'a>(
84    table: &'a Table<'a>,
85    content: &str,
86    line_table: &LineOffsetTable,
87) -> Option<(String, String, String, Range)> {
88    if let Some(module_val) = get_table_val(table, "module") {
89        let module_str = module_val.as_str()?;
90        let name_range = span_to_range(content, line_table, module_val.span);
91        let (g, a) = module_str.split_once(':')?;
92        return Some((
93            g.to_string(),
94            a.to_string(),
95            module_str.to_string(),
96            name_range,
97        ));
98    }
99    let group_val = get_table_val(table, "group")?;
100    let name_val = get_table_val(table, "name")?;
101    let g = group_val.as_str()?.to_string();
102    let a = name_val.as_str()?.to_string();
103    let name_str = format!("{g}:{a}");
104    let name_range = span_to_range(content, line_table, name_val.span);
105    Some((g, a, name_str, name_range))
106}
107
108fn extract_version(
109    table: &Table<'_>,
110    content: &str,
111    line_table: &LineOffsetTable,
112    version_refs: &HashMap<String, String>,
113) -> (Option<String>, Option<Range>) {
114    let Some(version_val) = get_table_val(table, "version") else {
115        return (None, None);
116    };
117
118    if let Some(ver_str) = version_val.as_str() {
119        let range = span_to_range(content, line_table, version_val.span);
120        return (Some(ver_str.to_string()), Some(range));
121    }
122
123    // version.ref = "alias" — toml-span represents dotted keys as nested tables
124    if let Some(version_table) = version_val.as_table()
125        && let Some(ref_val) = get_table_val(version_table, "ref")
126        && let Some(ref_key) = ref_val.as_str()
127    {
128        let resolved = version_refs.get(ref_key).cloned();
129        let range = span_to_range(content, line_table, ref_val.span);
130        return (resolved, Some(range));
131    }
132
133    (None, None)
134}
135
136fn span_to_range(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Range {
137    // toml-span string spans already exclude surrounding quotes
138    let start = line_table.byte_offset_to_position(content, span.start);
139    let end = line_table.byte_offset_to_position(content, span.end);
140    Range::new(start, end)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn make_uri() -> Uri {
148        Uri::from_file_path("/project/gradle/libs.versions.toml").unwrap()
149    }
150
151    #[test]
152    fn test_parse_simple_catalog() {
153        let content = r#"[versions]
154spring = "3.2.0"
155guava = "33.0.0-jre"
156
157[libraries]
158spring-boot = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring" }
159guava = { module = "com.google.guava:guava", version.ref = "guava" }
160"#;
161        let result = parse_version_catalog(content, &make_uri()).unwrap();
162        assert_eq!(result.dependencies.len(), 2);
163
164        let spring = result
165            .dependencies
166            .iter()
167            .find(|d| d.name == "org.springframework.boot:spring-boot-starter")
168            .unwrap();
169        assert_eq!(spring.version_req, Some("3.2.0".into()));
170        assert_eq!(spring.group_id, "org.springframework.boot");
171        assert_eq!(spring.artifact_id, "spring-boot-starter");
172    }
173
174    #[test]
175    fn test_parse_inline_version() {
176        let content = "[libraries]\njunit = { module = \"junit:junit\", version = \"4.13.2\" }\n";
177        let result = parse_version_catalog(content, &make_uri()).unwrap();
178        assert_eq!(result.dependencies.len(), 1);
179        assert_eq!(result.dependencies[0].version_req, Some("4.13.2".into()));
180    }
181
182    #[test]
183    fn test_parse_group_name_format() {
184        let content = "[libraries]\ncommons = { group = \"org.apache.commons\", name = \"commons-lang3\", version = \"3.14.0\" }\n";
185        let result = parse_version_catalog(content, &make_uri()).unwrap();
186        assert_eq!(result.dependencies.len(), 1);
187        assert_eq!(
188            result.dependencies[0].name,
189            "org.apache.commons:commons-lang3"
190        );
191        assert_eq!(result.dependencies[0].version_req, Some("3.14.0".into()));
192    }
193
194    #[test]
195    fn test_parse_no_version() {
196        let content = "[libraries]\nspring-bom = { module = \"org.springframework.boot:spring-boot-dependencies\" }\n";
197        let result = parse_version_catalog(content, &make_uri()).unwrap();
198        assert_eq!(result.dependencies.len(), 1);
199        assert!(result.dependencies[0].version_req.is_none());
200    }
201
202    #[test]
203    fn test_parse_empty_catalog() {
204        let content = "[versions]\n[libraries]\n";
205        let result = parse_version_catalog(content, &make_uri()).unwrap();
206        assert!(result.dependencies.is_empty());
207    }
208
209    #[test]
210    fn test_parse_invalid_toml() {
211        let content = "[libraries\nbad toml";
212        let result = parse_version_catalog(content, &make_uri());
213        assert!(result.is_err());
214    }
215
216    #[test]
217    fn test_parse_missing_libraries_section() {
218        let content = "[versions]\nspring = \"3.2.0\"\n";
219        let result = parse_version_catalog(content, &make_uri()).unwrap();
220        assert!(result.dependencies.is_empty());
221    }
222
223    #[test]
224    fn test_strip_quotes() {
225        // toml-span handles quote stripping internally; this test verifies that
226        // string values returned via as_str() don't include surrounding quotes.
227        let content = "[libraries]\njunit = { module = \"junit:junit\", version = \"4.13.2\" }\n";
228        let result = parse_version_catalog(content, &make_uri()).unwrap();
229        assert_eq!(result.dependencies[0].name, "junit:junit");
230        assert_eq!(
231            result.dependencies[0].version_req,
232            Some("4.13.2".to_string())
233        );
234    }
235
236    #[test]
237    fn test_version_ref_position_tracking() {
238        let content = r#"[versions]
239spring = "3.2.0"
240
241[libraries]
242spring-boot = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring" }
243"#;
244        let result = parse_version_catalog(content, &make_uri()).unwrap();
245        let dep = &result.dependencies[0];
246
247        // name_range should point to the module value on line 4
248        assert_eq!(dep.name_range.start.line, 4);
249        assert!(dep.name_range.start.character > 0);
250
251        // version_range should also be on line 4, not line 0
252        let vr = dep.version_range.as_ref().unwrap();
253        assert_eq!(vr.start.line, 4);
254        assert!(vr.start.character > 0);
255    }
256
257    #[test]
258    fn test_duplicate_version_ref_different_lines() {
259        let content = r#"[versions]
260hilt = "2.50"
261
262[libraries]
263hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
264hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
265"#;
266        let result = parse_version_catalog(content, &make_uri()).unwrap();
267        assert_eq!(result.dependencies.len(), 2);
268
269        let d0 = &result.dependencies[0];
270        let d1 = &result.dependencies[1];
271        let vr0 = d0.version_range.as_ref().unwrap();
272        let vr1 = d1.version_range.as_ref().unwrap();
273
274        // Each dependency's version range must be on its own line
275        assert_ne!(vr0.start.line, vr1.start.line);
276        assert_eq!(vr0.start.line, d0.name_range.start.line);
277        assert_eq!(vr1.start.line, d1.name_range.start.line);
278    }
279
280    #[test]
281    fn test_unresolved_version_ref() {
282        let content = "[libraries]\nspring-boot = { module = \"org.springframework.boot:spring-boot-starter\", version.ref = \"missing\" }\n";
283        let result = parse_version_catalog(content, &make_uri()).unwrap();
284        assert_eq!(result.dependencies.len(), 1);
285        assert!(result.dependencies[0].version_req.is_none());
286    }
287}