Skip to main content

normalize_manifest/
gradle.rs

1//! Parsers for Gradle build files.
2//!
3//! - `build.gradle` (Groovy DSL)
4//! - `build.gradle.kts` (Kotlin DSL)
5//!
6//! Both share the same extraction logic since the dependency declaration patterns
7//! overlap significantly. The key difference is quoting style:
8//! - Groovy: `implementation 'group:artifact:version'`
9//! - Kotlin: `implementation("group:artifact:version")`
10
11use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
12
13/// Parser for `build.gradle` (Groovy DSL) files.
14pub struct GradleParser;
15
16/// Parser for `build.gradle.kts` (Kotlin DSL) files.
17pub struct GradleKtsParser;
18
19impl ManifestParser for GradleParser {
20    fn filename(&self) -> &'static str {
21        "build.gradle"
22    }
23
24    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
25        parse_gradle(content)
26    }
27}
28
29impl ManifestParser for GradleKtsParser {
30    fn filename(&self) -> &'static str {
31        "build.gradle.kts"
32    }
33
34    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
35        parse_gradle(content)
36    }
37}
38
39fn parse_gradle(content: &str) -> Result<ParsedManifest, ManifestError> {
40    let mut deps = Vec::new();
41    let mut in_deps_block = false;
42    let mut brace_depth: i32 = 0;
43
44    for line in content.lines() {
45        let trimmed = line.trim();
46
47        if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("/*") {
48            continue;
49        }
50
51        // Enter dependencies { ... } block
52        if !in_deps_block
53            && (trimmed == "dependencies {"
54                || trimmed == "dependencies{"
55                || trimmed.starts_with("dependencies {")
56                || trimmed.starts_with("dependencies{"))
57        {
58            in_deps_block = true;
59            brace_depth = 1;
60            continue;
61        }
62
63        if in_deps_block {
64            for ch in trimmed.chars() {
65                match ch {
66                    '{' => brace_depth += 1,
67                    '}' => brace_depth -= 1,
68                    _ => {}
69                }
70            }
71
72            if brace_depth <= 0 {
73                in_deps_block = false;
74                continue;
75            }
76
77            if let Some(dep) = parse_gradle_dep_line(trimmed) {
78                deps.push(dep);
79            }
80        }
81    }
82
83    Ok(ParsedManifest {
84        ecosystem: "gradle",
85        name: None,
86        version: None,
87        dependencies: deps,
88    })
89}
90
91/// Configuration names that map to dependency kinds.
92///
93/// See: https://docs.gradle.org/current/userguide/java_library_plugin.html#sec:java_library_configurations_graph
94fn config_kind(config: &str) -> Option<DepKind> {
95    let config = config.trim_end_matches('(');
96    match config {
97        "implementation" | "api" | "compileOnly" | "runtimeOnly" | "compile" | "runtime" => {
98            Some(DepKind::Normal)
99        }
100        "testImplementation"
101        | "testCompileOnly"
102        | "testRuntimeOnly"
103        | "testCompile"
104        | "testRuntime"
105        | "androidTestImplementation" => Some(DepKind::Dev),
106        _ if config.ends_with("TestImplementation")
107            || config.ends_with("TestCompile")
108            || config.starts_with("debug")
109            || config.starts_with("release") =>
110        {
111            Some(DepKind::Normal)
112        }
113        _ => None,
114    }
115}
116
117fn parse_gradle_dep_line(line: &str) -> Option<DeclaredDep> {
118    // Find first word (the configuration name)
119    let word_end = line.find(|c: char| !c.is_alphanumeric() && c != '_')?;
120    let config = &line[..word_end];
121    let kind = config_kind(config)?;
122
123    let rest = line[word_end..].trim();
124
125    // Extract the coord string — could be:
126    //   "group:artifact:version"     (Kotlin/Groovy double-quoted)
127    //   'group:artifact:version'     (Groovy single-quoted)
128    //   group("artifact") ...        (Kotlin type-safe accessors — skip, no version)
129    let coord = if let Some(inner) = rest.strip_prefix('"') {
130        let end = inner.find('"')?;
131        &inner[..end]
132    } else if let Some(inner) = rest.strip_prefix('\'') {
133        let end = inner.find('\'')?;
134        &inner[..end]
135    } else if let Some(inner) = rest.strip_prefix('(') {
136        // Kotlin: implementation("...")
137        let inner = inner.trim_start();
138        if let Some(inner2) = inner.strip_prefix('"') {
139            let end = inner2.find('"')?;
140            &inner2[..end]
141        } else if let Some(inner2) = inner.strip_prefix('\'') {
142            let end = inner2.find('\'')?;
143            &inner2[..end]
144        } else {
145            return None;
146        }
147    } else {
148        return None;
149    };
150
151    // Parse `group:artifact:version` — also handle `group:artifact` (no version)
152    let parts: Vec<&str> = coord.splitn(3, ':').collect();
153    match parts.as_slice() {
154        [group, artifact, version] => Some(DeclaredDep {
155            name: format!("{}:{}", group, artifact),
156            version_req: Some(version.to_string()),
157            kind,
158        }),
159        [group, artifact] => Some(DeclaredDep {
160            name: format!("{}:{}", group, artifact),
161            version_req: None,
162            kind,
163        }),
164        _ => None,
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::ManifestParser;
172
173    #[test]
174    fn test_parse_build_gradle_groovy() {
175        let content = r#"
176plugins {
177    id 'java'
178}
179
180dependencies {
181    implementation 'com.google.guava:guava:32.1.0-jre'
182    implementation 'org.springframework:spring-core:6.0.0'
183    testImplementation 'junit:junit:4.13.2'
184    compileOnly 'org.projectlombok:lombok:1.18.28'
185}
186"#;
187        let m = GradleParser.parse(content).unwrap();
188        assert_eq!(m.ecosystem, "gradle");
189        assert_eq!(m.dependencies.len(), 4);
190
191        let guava = m
192            .dependencies
193            .iter()
194            .find(|d| d.name == "com.google.guava:guava")
195            .unwrap();
196        assert_eq!(guava.version_req.as_deref(), Some("32.1.0-jre"));
197        assert_eq!(guava.kind, DepKind::Normal);
198
199        let junit = m
200            .dependencies
201            .iter()
202            .find(|d| d.name == "junit:junit")
203            .unwrap();
204        assert_eq!(junit.kind, DepKind::Dev);
205    }
206
207    #[test]
208    fn test_parse_build_gradle_kts() {
209        let content = r#"
210dependencies {
211    implementation("com.google.guava:guava:32.1.0-jre")
212    testImplementation("org.junit.jupiter:junit-jupiter:5.9.3")
213    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
214}
215"#;
216        let m = GradleKtsParser.parse(content).unwrap();
217        assert_eq!(m.ecosystem, "gradle");
218        assert_eq!(m.dependencies.len(), 3);
219
220        let coroutines = m
221            .dependencies
222            .iter()
223            .find(|d| d.name.contains("kotlinx-coroutines-core"))
224            .unwrap();
225        assert_eq!(coroutines.version_req.as_deref(), Some("1.7.1"));
226    }
227}