Skip to main content

deps_gradle/parser/
groovy.rs

1//! Parser for Gradle Groovy DSL (build.gradle).
2//!
3//! Regex-based extraction of dependency declarations from dependencies { } blocks.
4
5use crate::error::Result;
6use crate::parser::{GradleParseResult, find_name_range, find_version_range};
7use crate::types::GradleDependency;
8use regex::Regex;
9use std::sync::OnceLock;
10use tower_lsp_server::ls_types::Uri;
11
12/// Matches: implementation('group:artifact:version') or implementation("group:artifact:version")
13static RE_WITH_PARENS: OnceLock<Regex> = OnceLock::new();
14/// Matches: implementation 'group:artifact:version' or implementation "group:artifact:version"
15static RE_WITHOUT_PARENS: OnceLock<Regex> = OnceLock::new();
16/// Matches: implementation 'group:artifact' or implementation "group:artifact" (no version)
17static RE_NO_VERSION_WITHOUT_PARENS: OnceLock<Regex> = OnceLock::new();
18/// Matches: implementation('group:artifact') (no version, with parens)
19static RE_NO_VERSION_WITH_PARENS: OnceLock<Regex> = OnceLock::new();
20
21const CONFIGURATIONS: &[&str] = &[
22    "implementation",
23    "api",
24    "compileOnly",
25    "runtimeOnly",
26    "testImplementation",
27    "testRuntimeOnly",
28    "annotationProcessor",
29    "kapt",
30    "classpath",
31    "ksp",
32    "testCompileOnly",
33    "compile",
34    "testCompile",
35    "provided",
36];
37
38fn re_with_parens() -> &'static Regex {
39    RE_WITH_PARENS.get_or_init(|| {
40        Regex::new(r#"(\w+)\(\s*['"]([^:'"]+):([^:'"]+):([^'"]+)['"]\s*\)"#).unwrap()
41    })
42}
43
44fn re_without_parens() -> &'static Regex {
45    RE_WITHOUT_PARENS
46        .get_or_init(|| Regex::new(r#"(\w+)\s+['"]([^:'"]+):([^:'"]+):([^'"]+)['"]"#).unwrap())
47}
48
49fn re_no_version_without_parens() -> &'static Regex {
50    RE_NO_VERSION_WITHOUT_PARENS
51        .get_or_init(|| Regex::new(r#"(\w+)\s+['"]([^:'"]+):([^:'"]+)['"]"#).unwrap())
52}
53
54fn re_no_version_with_parens() -> &'static Regex {
55    RE_NO_VERSION_WITH_PARENS
56        .get_or_init(|| Regex::new(r#"(\w+)\(\s*['"]([^:'"]+):([^:'"]+)['"]\s*\)"#).unwrap())
57}
58
59pub fn parse_groovy_dsl(content: &str, uri: &Uri) -> Result<GradleParseResult> {
60    let mut dependencies = Vec::new();
61
62    let mut brace_depth: i32 = 0;
63    let mut in_dependencies_block = false;
64    let mut deps_brace_depth: i32 = 0;
65
66    for (line_idx, line) in content.lines().enumerate() {
67        let trimmed = line.trim();
68
69        if !in_dependencies_block
70            && (trimmed == "dependencies {" || trimmed.starts_with("dependencies {"))
71        {
72            in_dependencies_block = true;
73            deps_brace_depth = brace_depth + 1;
74        }
75
76        for ch in line.chars() {
77            match ch {
78                '{' => brace_depth += 1,
79                '}' => {
80                    brace_depth -= 1;
81                    if in_dependencies_block && brace_depth < deps_brace_depth {
82                        in_dependencies_block = false;
83                    }
84                }
85                _ => {}
86            }
87        }
88
89        if !in_dependencies_block && !line.trim_start().starts_with("dependencies") {
90            continue;
91        }
92
93        let line_u32 = line_idx as u32;
94        let mut matched_positions: Vec<usize> = Vec::new();
95
96        // Pattern 1: with parens and version
97        for caps in re_with_parens().captures_iter(line) {
98            let config = caps.get(1).map_or("", |m| m.as_str());
99            if !CONFIGURATIONS.contains(&config) {
100                continue;
101            }
102            let start = caps.get(0).map_or(0, |m| m.start());
103            matched_positions.push(start);
104
105            let group_id = caps.get(2).map_or("", |m| m.as_str()).to_string();
106            let artifact_id = caps.get(3).map_or("", |m| m.as_str()).to_string();
107            let version = caps.get(4).map_or("", |m| m.as_str()).trim().to_string();
108            let name = format!("{group_id}:{artifact_id}");
109
110            let name_range = find_name_range(line, line_u32, &group_id, &artifact_id);
111            let version_range = find_version_range(line, line_u32, &version);
112
113            dependencies.push(GradleDependency {
114                group_id,
115                artifact_id,
116                name,
117                name_range,
118                version_req: Some(version),
119                version_range: Some(version_range),
120                configuration: config.to_string(),
121            });
122        }
123
124        // Pattern 2: without parens and with version
125        for caps in re_without_parens().captures_iter(line) {
126            let config = caps.get(1).map_or("", |m| m.as_str());
127            if !CONFIGURATIONS.contains(&config) {
128                continue;
129            }
130            let start = caps.get(0).map_or(0, |m| m.start());
131            if matched_positions.contains(&start) {
132                continue;
133            }
134            matched_positions.push(start);
135
136            let group_id = caps.get(2).map_or("", |m| m.as_str()).to_string();
137            let artifact_id = caps.get(3).map_or("", |m| m.as_str()).to_string();
138            let version = caps.get(4).map_or("", |m| m.as_str()).trim().to_string();
139            let name = format!("{group_id}:{artifact_id}");
140
141            let name_range = find_name_range(line, line_u32, &group_id, &artifact_id);
142            let version_range = find_version_range(line, line_u32, &version);
143
144            dependencies.push(GradleDependency {
145                group_id,
146                artifact_id,
147                name,
148                name_range,
149                version_req: Some(version),
150                version_range: Some(version_range),
151                configuration: config.to_string(),
152            });
153        }
154
155        // Pattern 3: with parens, no version
156        for caps in re_no_version_with_parens().captures_iter(line) {
157            let config = caps.get(1).map_or("", |m| m.as_str());
158            if !CONFIGURATIONS.contains(&config) {
159                continue;
160            }
161            let start = caps.get(0).map_or(0, |m| m.start());
162            if matched_positions.contains(&start) {
163                continue;
164            }
165            matched_positions.push(start);
166
167            let group_id = caps.get(2).map_or("", |m| m.as_str()).to_string();
168            let artifact_id = caps.get(3).map_or("", |m| m.as_str()).to_string();
169            let name = format!("{group_id}:{artifact_id}");
170            let name_range = find_name_range(line, line_u32, &group_id, &artifact_id);
171
172            dependencies.push(GradleDependency {
173                group_id,
174                artifact_id,
175                name,
176                name_range,
177                version_req: None,
178                version_range: None,
179                configuration: config.to_string(),
180            });
181        }
182
183        // Pattern 4: without parens, no version
184        for caps in re_no_version_without_parens().captures_iter(line) {
185            let config = caps.get(1).map_or("", |m| m.as_str());
186            if !CONFIGURATIONS.contains(&config) {
187                continue;
188            }
189            let start = caps.get(0).map_or(0, |m| m.start());
190            if matched_positions.contains(&start) {
191                continue;
192            }
193
194            let group_id = caps.get(2).map_or("", |m| m.as_str()).to_string();
195            let artifact_id = caps.get(3).map_or("", |m| m.as_str()).to_string();
196            let name = format!("{group_id}:{artifact_id}");
197            let name_range = find_name_range(line, line_u32, &group_id, &artifact_id);
198
199            dependencies.push(GradleDependency {
200                group_id,
201                artifact_id,
202                name,
203                name_range,
204                version_req: None,
205                version_range: None,
206                configuration: config.to_string(),
207            });
208        }
209    }
210
211    Ok(GradleParseResult {
212        dependencies,
213        uri: uri.clone(),
214    })
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn make_uri() -> Uri {
222        Uri::from_file_path("/project/build.gradle").unwrap()
223    }
224
225    #[test]
226    fn test_parse_single_quotes() {
227        let content = "dependencies {\n    implementation 'org.springframework.boot:spring-boot-starter:3.2.0'\n}\n";
228        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
229        assert_eq!(result.dependencies.len(), 1);
230        assert_eq!(
231            result.dependencies[0].name,
232            "org.springframework.boot:spring-boot-starter"
233        );
234        assert_eq!(result.dependencies[0].version_req, Some("3.2.0".into()));
235    }
236
237    #[test]
238    fn test_parse_double_quotes() {
239        let content =
240            "dependencies {\n    implementation \"com.google.guava:guava:33.0.0-jre\"\n}\n";
241        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
242        assert_eq!(result.dependencies.len(), 1);
243        assert_eq!(result.dependencies[0].name, "com.google.guava:guava");
244        assert_eq!(
245            result.dependencies[0].version_req,
246            Some("33.0.0-jre".into())
247        );
248    }
249
250    #[test]
251    fn test_parse_with_parens() {
252        let content = "dependencies {\n    implementation('junit:junit:4.13.2')\n}\n";
253        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
254        assert_eq!(result.dependencies.len(), 1);
255        assert_eq!(result.dependencies[0].name, "junit:junit");
256    }
257
258    #[test]
259    fn test_parse_multiple_configurations() {
260        let content = "dependencies {\n    implementation 'org.springframework.boot:spring-boot-starter:3.2.0'\n    testImplementation 'junit:junit:4.13.2'\n    compileOnly 'org.projectlombok:lombok:1.18.30'\n    runtimeOnly 'mysql:mysql-connector-java:8.0.33'\n}\n";
261        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
262        assert_eq!(result.dependencies.len(), 4);
263        assert_eq!(result.dependencies[1].configuration, "testImplementation");
264        assert_eq!(result.dependencies[2].configuration, "compileOnly");
265    }
266
267    #[test]
268    fn test_ignore_unknown_configurations() {
269        let content = "dependencies {\n    implementation 'a:b:1.0'\n    unknown 'c:d:2.0'\n}\n";
270        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
271        assert_eq!(result.dependencies.len(), 1);
272    }
273
274    #[test]
275    fn test_parse_no_version() {
276        let content = "dependencies {\n    implementation 'org.springframework.boot:spring-boot-starter'\n}\n";
277        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
278        assert_eq!(result.dependencies.len(), 1);
279        assert!(result.dependencies[0].version_req.is_none());
280    }
281
282    #[test]
283    fn test_empty_block() {
284        let content = "dependencies {\n}\n";
285        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
286        assert!(result.dependencies.is_empty());
287    }
288
289    #[test]
290    fn test_position_tracking() {
291        let content = "dependencies {\n    implementation 'com.example:lib:1.0.0'\n}\n";
292        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
293        assert_eq!(result.dependencies.len(), 1);
294        let dep = &result.dependencies[0];
295        assert_eq!(dep.name_range.start.line, 1);
296        assert!(dep.version_range.is_some());
297    }
298
299    #[test]
300    fn test_no_dependencies_block() {
301        let content = "apply plugin: 'java'\n";
302        let result = parse_groovy_dsl(content, &make_uri()).unwrap();
303        assert!(result.dependencies.is_empty());
304    }
305}