1use 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 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 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 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 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 assert_eq!(dep.name_range.start.line, 4);
249 assert!(dep.name_range.start.character > 0);
250
251 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 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}