Skip to main content

deps_gradle/parser/
mod.rs

1//! Gradle manifest parser dispatcher.
2//!
3//! Routes parsing to the appropriate module based on file extension/name.
4
5pub mod catalog;
6pub mod groovy;
7pub mod kotlin;
8pub mod properties;
9pub mod settings;
10
11use crate::types::GradleDependency;
12use deps_core::Result;
13use std::any::Any;
14use std::collections::HashMap;
15use tower_lsp_server::ls_types::{Position, Range, Uri};
16
17pub use deps_core::lsp_helpers::LineOffsetTable;
18
19#[derive(Debug)]
20pub struct GradleParseResult {
21    pub dependencies: Vec<GradleDependency>,
22    pub uri: Uri,
23}
24
25/// Resolves `$var` and `${var}` references in dependency versions using the given properties map.
26///
27/// If a version is a variable reference and the variable is found in `properties`,
28/// the version is replaced with the resolved value. The version_range is kept as-is
29/// (pointing to the variable reference in source).
30pub fn resolve_variables(deps: &mut [GradleDependency], properties: &HashMap<String, String>) {
31    for dep in deps.iter_mut() {
32        if let Some(ref ver) = dep.version_req
33            && let Some(resolved) = resolve_variable_ref(ver.as_str(), properties)
34        {
35            dep.version_req = Some(resolved.into());
36        }
37    }
38}
39
40/// Returns the resolved value if `value` is a `$name` or `${name}` reference. Returns `None` otherwise.
41fn resolve_variable_ref(value: &str, properties: &HashMap<String, String>) -> Option<String> {
42    let trimmed = value.trim();
43    if let Some(name) = trimmed.strip_circumfix("${", '}') {
44        properties.get(name).cloned()
45    } else if let Some(name) = trimmed.strip_prefix('$') {
46        properties.get(name).cloned()
47    } else {
48        None
49    }
50}
51
52pub fn parse_gradle(content: &str, uri: &Uri) -> Result<GradleParseResult> {
53    let path = uri.path().to_string();
54    let mut result = if path.ends_with("libs.versions.toml") {
55        catalog::parse_version_catalog(content, uri)?
56    } else if path.ends_with("settings.gradle.kts") || path.ends_with("settings.gradle") {
57        settings::parse_settings(content, uri)?
58    } else if path.ends_with(".gradle.kts") {
59        kotlin::parse_kotlin_dsl(content, uri)?
60    } else if path.ends_with(".gradle") {
61        groovy::parse_groovy_dsl(content, uri)?
62    } else {
63        return Ok(GradleParseResult {
64            dependencies: vec![],
65            uri: uri.clone(),
66        });
67    };
68
69    // Resolve variable references for build files (not catalogs or settings)
70    if (path.ends_with("build.gradle.kts") || path.ends_with("build.gradle"))
71        && let Some(dir) = std::path::Path::new(&path).parent()
72    {
73        let props = properties::load_gradle_properties(dir);
74        if !props.is_empty() {
75            resolve_variables(&mut result.dependencies, &props);
76        }
77    }
78
79    Ok(result)
80}
81
82impl deps_core::ParseResult for GradleParseResult {
83    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
84        self.dependencies
85            .iter()
86            .map(|d| d as &dyn deps_core::Dependency)
87            .collect()
88    }
89
90    fn workspace_root(&self) -> Option<&std::path::Path> {
91        None
92    }
93
94    fn uri(&self) -> &Uri {
95        &self.uri
96    }
97
98    fn as_any(&self) -> &dyn Any {
99        self
100    }
101}
102
103/// Returns the number of UTF-16 code units in `s`.
104pub(crate) fn utf16_len(s: &str) -> usize {
105    s.chars().map(|c| c.len_utf16()).sum()
106}
107
108/// Finds the LSP range of `"group_id:artifact_id"` within `line`.
109pub(crate) fn find_name_range(
110    line: &str,
111    line_idx: u32,
112    group_id: &str,
113    artifact_id: &str,
114) -> Range {
115    let search = format!("{group_id}:{artifact_id}");
116    if let Some(col) = line.find(&search) {
117        let col_u32 = utf16_len(&line[..col]) as u32;
118        let end_u32 = col_u32 + utf16_len(&search) as u32;
119        Range::new(
120            Position::new(line_idx, col_u32),
121            Position::new(line_idx, end_u32),
122        )
123    } else {
124        Range::default()
125    }
126}
127
128/// Finds the LSP range of `version` in `line` after the second `:`.
129pub(crate) fn find_version_range(line: &str, line_idx: u32, version: &str) -> Range {
130    let second_colon = line
131        .char_indices()
132        .filter(|(_, c)| *c == ':')
133        .nth(1)
134        .map(|(i, _)| i);
135
136    if let Some(colon_pos) = second_colon {
137        let after_colon = &line[colon_pos + 1..];
138        if let Some(rel) = after_colon.find(version) {
139            let abs_start = colon_pos + 1 + rel;
140            let col_start = utf16_len(&line[..abs_start]) as u32;
141            let col_end = col_start + utf16_len(version) as u32;
142            return Range::new(
143                Position::new(line_idx, col_start),
144                Position::new(line_idx, col_end),
145            );
146        }
147    }
148    Range::default()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn make_uri(path: &str) -> Uri {
156        deps_core::test_util::test_uri(path)
157    }
158
159    #[test]
160    fn test_dispatch_catalog() {
161        let content = "[versions]\nspring = \"3.2.0\"\n\n[libraries]\nspring-boot = { module = \"org.springframework.boot:spring-boot-starter\", version.ref = \"spring\" }\n";
162        let uri = make_uri("/project/gradle/libs.versions.toml");
163        let result = parse_gradle(content, &uri).unwrap();
164        assert!(!result.dependencies.is_empty());
165    }
166
167    #[test]
168    fn test_dispatch_kotlin() {
169        let content = "dependencies {\n    implementation(\"org.springframework.boot:spring-boot-starter:3.2.0\")\n}\n";
170        let uri = make_uri("/project/build.gradle.kts");
171        let result = parse_gradle(content, &uri).unwrap();
172        assert_eq!(result.dependencies.len(), 1);
173    }
174
175    #[test]
176    fn test_dispatch_groovy() {
177        let content = "dependencies {\n    implementation 'org.springframework.boot:spring-boot-starter:3.2.0'\n}\n";
178        let uri = make_uri("/project/build.gradle");
179        let result = parse_gradle(content, &uri).unwrap();
180        assert_eq!(result.dependencies.len(), 1);
181    }
182
183    #[test]
184    fn test_dispatch_settings_gradle() {
185        let content = "pluginManagement {\n    plugins {\n        id \"org.jetbrains.kotlin.jvm\" version \"2.1.10\"\n    }\n}\n";
186        let uri = make_uri("/project/settings.gradle");
187        let result = parse_gradle(content, &uri).unwrap();
188        assert_eq!(result.dependencies.len(), 1);
189    }
190
191    #[test]
192    fn test_dispatch_settings_gradle_kts() {
193        let content = "pluginManagement {\n    plugins {\n        id(\"org.springframework.boot\") version \"3.2.0\"\n    }\n}\n";
194        let uri = make_uri("/project/settings.gradle.kts");
195        let result = parse_gradle(content, &uri).unwrap();
196        assert_eq!(result.dependencies.len(), 1);
197    }
198
199    #[test]
200    fn test_dispatch_unknown() {
201        let uri = make_uri("/project/something.xml");
202        let result = parse_gradle("", &uri).unwrap();
203        assert!(result.dependencies.is_empty());
204    }
205
206    #[test]
207    fn test_resolve_variables_dollar_brace() {
208        let props: HashMap<String, String> =
209            [("kotlinVersion".to_string(), "2.1.10".to_string())].into();
210        let mut deps = vec![GradleDependency {
211            group_id: "org.jetbrains.kotlin".into(),
212            artifact_id: "kotlin-stdlib".into(),
213            name: "org.jetbrains.kotlin:kotlin-stdlib".into(),
214            name_range: Range::default(),
215            version_req: Some("${kotlinVersion}".into()),
216            version_range: None,
217            configuration: "implementation".into(),
218        }];
219        resolve_variables(&mut deps, &props);
220        assert_eq!(deps[0].version_req, Some("2.1.10".into()));
221    }
222
223    #[test]
224    fn test_resolve_variables_dollar_plain() {
225        let props: HashMap<String, String> =
226            [("springVersion".to_string(), "3.2.0".to_string())].into();
227        let mut deps = vec![GradleDependency {
228            group_id: "org.springframework.boot".into(),
229            artifact_id: "spring-boot-starter".into(),
230            name: "org.springframework.boot:spring-boot-starter".into(),
231            name_range: Range::default(),
232            version_req: Some("$springVersion".into()),
233            version_range: None,
234            configuration: "implementation".into(),
235        }];
236        resolve_variables(&mut deps, &props);
237        assert_eq!(deps[0].version_req, Some("3.2.0".into()));
238    }
239
240    #[test]
241    fn test_resolve_variables_not_found_keeps_raw() {
242        let props: HashMap<String, String> = HashMap::new();
243        let mut deps = vec![GradleDependency {
244            group_id: "com.example".into(),
245            artifact_id: "lib".into(),
246            name: "com.example:lib".into(),
247            name_range: Range::default(),
248            version_req: Some("$unknownVar".into()),
249            version_range: None,
250            configuration: "implementation".into(),
251        }];
252        resolve_variables(&mut deps, &props);
253        assert_eq!(deps[0].version_req, Some("$unknownVar".into()));
254    }
255
256    #[test]
257    fn test_resolve_variables_literal_version_unchanged() {
258        let props: HashMap<String, String> = [("v".to_string(), "9.9.9".to_string())].into();
259        let mut deps = vec![GradleDependency {
260            group_id: "com.example".into(),
261            artifact_id: "lib".into(),
262            name: "com.example:lib".into(),
263            name_range: Range::default(),
264            version_req: Some("1.2.3".into()),
265            version_range: None,
266            configuration: "implementation".into(),
267        }];
268        resolve_variables(&mut deps, &props);
269        assert_eq!(deps[0].version_req, Some("1.2.3".into()));
270    }
271
272    #[test]
273    fn test_parse_result_trait() {
274        use deps_core::ParseResult;
275
276        let uri = make_uri("/project/build.gradle");
277        let result = parse_gradle("", &uri).unwrap();
278        assert!(result.dependencies().is_empty());
279        assert!(result.workspace_root().is_none());
280        assert!(result.as_any().is::<GradleParseResult>());
281    }
282
283    #[test]
284    fn test_line_offset_table() {
285        let content = "line0\nline1\nline2";
286        let table = LineOffsetTable::new(content);
287        let pos = table.byte_offset_to_position(content, 6);
288        assert_eq!(pos.line, 1);
289        assert_eq!(pos.character, 0);
290
291        let pos = table.byte_offset_to_position(content, 8);
292        assert_eq!(pos.line, 1);
293        assert_eq!(pos.character, 2);
294    }
295
296    #[test]
297    fn test_find_name_range() {
298        let line = "    implementation(\"com.example:lib:1.0.0\")";
299        let range = find_name_range(line, 5, "com.example", "lib");
300        assert_eq!(range.start.line, 5);
301        assert!(range.start.character > 0);
302    }
303
304    #[test]
305    fn test_find_version_range() {
306        let line = "    implementation(\"com.example:lib:1.0.0\")";
307        let range = find_version_range(line, 5, "1.0.0");
308        assert_eq!(range.start.line, 5);
309        // "1.0.0" is 5 chars, end = start + 5
310        assert_eq!(range.end.character - range.start.character, 5);
311    }
312
313    #[test]
314    fn test_utf16_len_ascii() {
315        assert_eq!(utf16_len("hello"), 5);
316    }
317}