Skip to main content

deps_gradle/parser/
kotlin.rs

1//! Parser for Gradle Kotlin DSL (build.gradle.kts).
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")
13static RE_WITH_VERSION: OnceLock<Regex> = OnceLock::new();
14/// Matches: implementation("group:artifact") — no version
15static RE_NO_VERSION: OnceLock<Regex> = OnceLock::new();
16
17const CONFIGURATIONS: &[&str] = &[
18    "implementation",
19    "api",
20    "compileOnly",
21    "runtimeOnly",
22    "testImplementation",
23    "testRuntimeOnly",
24    "annotationProcessor",
25    "kapt",
26    "classpath",
27    "ksp",
28    "testCompileOnly",
29];
30
31fn re_with_version() -> &'static Regex {
32    RE_WITH_VERSION
33        .get_or_init(|| Regex::new(r#"(\w+)\(\s*"([^:"\s]+):([^:"\s]+):([^"]+)"\s*\)"#).unwrap())
34}
35
36fn re_no_version() -> &'static Regex {
37    RE_NO_VERSION.get_or_init(|| Regex::new(r#"(\w+)\(\s*"([^:"\s]+):([^:"\s"]+)"\s*\)"#).unwrap())
38}
39
40pub fn parse_kotlin_dsl(content: &str, uri: &Uri) -> Result<GradleParseResult> {
41    let mut dependencies = Vec::new();
42
43    // Track brace depth to detect dependencies { } block
44    let mut brace_depth: i32 = 0;
45    let mut in_dependencies_block = false;
46    let mut deps_brace_depth: i32 = 0;
47
48    for (line_idx, line) in content.lines().enumerate() {
49        let trimmed = line.trim();
50
51        // Detect entry into dependencies { block
52        if !in_dependencies_block
53            && (trimmed == "dependencies {" || trimmed.starts_with("dependencies {"))
54        {
55            in_dependencies_block = true;
56            deps_brace_depth = brace_depth + 1;
57        }
58
59        // Count braces
60        for ch in line.chars() {
61            match ch {
62                '{' => brace_depth += 1,
63                '}' => {
64                    brace_depth -= 1;
65                    if in_dependencies_block && brace_depth < deps_brace_depth {
66                        in_dependencies_block = false;
67                    }
68                }
69                _ => {}
70            }
71        }
72
73        if !in_dependencies_block && !line.trim_start().starts_with("dependencies") {
74            continue;
75        }
76
77        let line_u32 = line_idx as u32;
78
79        // Try pattern with version first
80        for caps in re_with_version().captures_iter(line) {
81            let config = caps.get(1).map_or("", |m| m.as_str());
82            if !CONFIGURATIONS.contains(&config) {
83                continue;
84            }
85
86            let group_id = caps.get(2).map_or("", |m| m.as_str()).to_string();
87            let artifact_id = caps.get(3).map_or("", |m| m.as_str()).to_string();
88            let version = caps.get(4).map_or("", |m| m.as_str()).trim().to_string();
89            let name = format!("{group_id}:{artifact_id}");
90
91            // name_range covers the full "group:artifact" portion of the string
92            let name_range = find_name_range(line, line_u32, &group_id, &artifact_id);
93            let version_range = find_version_range(line, line_u32, &version);
94
95            dependencies.push(GradleDependency {
96                group_id,
97                artifact_id,
98                name,
99                name_range,
100                version_req: Some(version),
101                version_range: Some(version_range),
102                configuration: config.to_string(),
103            });
104        }
105
106        // Try pattern without version (only if no versioned match on this line)
107        // Avoid double-matching lines that were already caught above
108        let already_matched: Vec<_> = re_with_version()
109            .captures_iter(line)
110            .filter_map(|c| {
111                let config = c.get(1)?.as_str();
112                CONFIGURATIONS
113                    .contains(&config)
114                    .then_some(c.get(0)?.start())
115            })
116            .collect();
117
118        for caps in re_no_version().captures_iter(line) {
119            let config = caps.get(1).map_or("", |m| m.as_str());
120            if !CONFIGURATIONS.contains(&config) {
121                continue;
122            }
123            // Skip if this match overlaps with a versioned match
124            let match_start = caps.get(0).map_or(0, |m| m.start());
125            if already_matched.contains(&match_start) {
126                continue;
127            }
128
129            let group_id = caps.get(2).map_or("", |m| m.as_str()).to_string();
130            let artifact_id = caps.get(3).map_or("", |m| m.as_str()).to_string();
131            let name = format!("{group_id}:{artifact_id}");
132            let name_range = find_name_range(line, line_u32, &group_id, &artifact_id);
133
134            dependencies.push(GradleDependency {
135                group_id,
136                artifact_id,
137                name,
138                name_range,
139                version_req: None,
140                version_range: None,
141                configuration: config.to_string(),
142            });
143        }
144    }
145
146    Ok(GradleParseResult {
147        dependencies,
148        uri: uri.clone(),
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn make_uri() -> Uri {
157        Uri::from_file_path("/project/build.gradle.kts").unwrap()
158    }
159
160    #[test]
161    fn test_parse_simple_kotlin() {
162        let content = r#"dependencies {
163    implementation("org.springframework.boot:spring-boot-starter:3.2.0")
164    testImplementation("junit:junit:4.13.2")
165}
166"#;
167        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
168        assert_eq!(result.dependencies.len(), 2);
169
170        let spring = &result.dependencies[0];
171        assert_eq!(spring.name, "org.springframework.boot:spring-boot-starter");
172        assert_eq!(spring.version_req, Some("3.2.0".into()));
173        assert_eq!(spring.configuration, "implementation");
174
175        let junit = &result.dependencies[1];
176        assert_eq!(junit.name, "junit:junit");
177        assert_eq!(junit.configuration, "testImplementation");
178    }
179
180    #[test]
181    fn test_parse_no_version() {
182        let content = r#"dependencies {
183    implementation("org.springframework.boot:spring-boot-starter")
184}
185"#;
186        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
187        assert_eq!(result.dependencies.len(), 1);
188        assert!(result.dependencies[0].version_req.is_none());
189    }
190
191    #[test]
192    fn test_ignore_non_dependency_configurations() {
193        let content = r#"dependencies {
194    implementation("a:b:1.0")
195    unknown("c:d:2.0")
196}
197"#;
198        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
199        assert_eq!(result.dependencies.len(), 1);
200        assert_eq!(result.dependencies[0].name, "a:b");
201    }
202
203    #[test]
204    fn test_parse_multiple_configurations() {
205        let content = r#"dependencies {
206    api("com.google.guava:guava:33.0.0-jre")
207    compileOnly("org.projectlombok:lombok:1.18.30")
208    runtimeOnly("mysql:mysql-connector-java:8.0.33")
209    kapt("com.google.dagger:dagger-compiler:2.51")
210}
211"#;
212        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
213        assert_eq!(result.dependencies.len(), 4);
214        assert_eq!(result.dependencies[0].configuration, "api");
215        assert_eq!(result.dependencies[1].configuration, "compileOnly");
216        assert_eq!(result.dependencies[2].configuration, "runtimeOnly");
217        assert_eq!(result.dependencies[3].configuration, "kapt");
218    }
219
220    #[test]
221    fn test_empty_dependencies_block() {
222        let content = "dependencies {\n}\n";
223        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
224        assert!(result.dependencies.is_empty());
225    }
226
227    #[test]
228    fn test_no_dependencies_block() {
229        let content = "plugins {\n    id(\"java\")\n}\n";
230        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
231        assert!(result.dependencies.is_empty());
232    }
233
234    #[test]
235    fn test_position_tracking() {
236        let content = "dependencies {\n    implementation(\"com.example:lib:1.0.0\")\n}\n";
237        let result = parse_kotlin_dsl(content, &make_uri()).unwrap();
238        assert_eq!(result.dependencies.len(), 1);
239        let dep = &result.dependencies[0];
240        // name_range should be on line 1
241        assert_eq!(dep.name_range.start.line, 1);
242        assert!(dep.version_range.is_some());
243        assert_eq!(dep.version_range.unwrap().start.line, 1);
244    }
245}