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