Skip to main content

deps_gradle/parser/
settings.rs

1//! Parser for settings.gradle and settings.gradle.kts files.
2//!
3//! Extracts plugin declarations from `pluginManagement { plugins { } }` blocks.
4
5use crate::error::Result;
6use crate::parser::{GradleParseResult, utf16_len};
7use crate::types::GradleDependency;
8use regex::Regex;
9use std::sync::OnceLock;
10use tower_lsp_server::ls_types::{Position, Range, Uri};
11
12/// Matches: id "plugin.id" version "1.0.0" (Groovy) or id("plugin.id") version "1.0.0" (Kotlin DSL)
13static RE_PLUGIN: OnceLock<Regex> = OnceLock::new();
14
15fn re_plugin() -> &'static Regex {
16    RE_PLUGIN.get_or_init(|| {
17        Regex::new(r#"id\s*\(?\s*['"]([^'"]+)['"]\s*\)?\s+version\s+['"]([^'"]+)['"]"#).unwrap()
18    })
19}
20
21/// Finds the LSP range of `plugin_id` within `line`.
22fn find_plugin_name_range(line: &str, line_idx: u32, plugin_id: &str) -> Range {
23    if let Some(col) = line.find(plugin_id) {
24        let col_u32 = utf16_len(&line[..col]) as u32;
25        let end_u32 = col_u32 + utf16_len(plugin_id) as u32;
26        Range::new(
27            Position::new(line_idx, col_u32),
28            Position::new(line_idx, end_u32),
29        )
30    } else {
31        Range::default()
32    }
33}
34
35/// Finds the LSP range of `version` in `line` after the `version` keyword.
36fn find_plugin_version_range(line: &str, line_idx: u32, version: &str) -> Range {
37    // Find "version" keyword, then locate the version string after it
38    if let Some(kw_pos) = line.find("version") {
39        let after_kw = &line[kw_pos + "version".len()..];
40        if let Some(rel) = after_kw.find(version) {
41            let abs_start = kw_pos + "version".len() + rel;
42            let col_start = utf16_len(&line[..abs_start]) as u32;
43            let col_end = col_start + utf16_len(version) as u32;
44            return Range::new(
45                Position::new(line_idx, col_start),
46                Position::new(line_idx, col_end),
47            );
48        }
49    }
50    Range::default()
51}
52
53/// Parses `pluginManagement { plugins { ... } }` blocks from settings.gradle / settings.gradle.kts.
54pub fn parse_settings(content: &str, uri: &Uri) -> Result<GradleParseResult> {
55    let mut dependencies = Vec::new();
56    let mut brace_depth: i32 = 0;
57    let mut in_plugin_management = false;
58    let mut pm_depth: i32 = 0;
59    let mut in_plugins = false;
60    let mut plugins_depth: i32 = 0;
61
62    for (line_idx, line) in content.lines().enumerate() {
63        let trimmed = line.trim();
64
65        // Detect pluginManagement { entry
66        if !in_plugin_management && trimmed.starts_with("pluginManagement") && trimmed.contains('{')
67        {
68            in_plugin_management = true;
69            pm_depth = brace_depth + 1;
70        }
71
72        // Detect plugins { entry inside pluginManagement
73        if in_plugin_management
74            && !in_plugins
75            && trimmed.starts_with("plugins")
76            && trimmed.contains('{')
77        {
78            in_plugins = true;
79            plugins_depth = brace_depth + 1;
80        }
81
82        // Count braces
83        for ch in line.chars() {
84            match ch {
85                '{' => brace_depth += 1,
86                '}' => {
87                    brace_depth -= 1;
88                    if in_plugins && brace_depth < plugins_depth {
89                        in_plugins = false;
90                    }
91                    if in_plugin_management && brace_depth < pm_depth {
92                        in_plugin_management = false;
93                    }
94                }
95                _ => {}
96            }
97        }
98
99        if !in_plugins {
100            continue;
101        }
102
103        let line_u32 = line_idx as u32;
104
105        for caps in re_plugin().captures_iter(line) {
106            let plugin_id = caps.get(1).map_or("", |m| m.as_str()).to_string();
107            let version = caps.get(2).map_or("", |m| m.as_str()).trim().to_string();
108
109            // Convention: pluginId -> group = pluginId, artifact = pluginId.gradle.plugin
110            let artifact_id = format!("{plugin_id}.gradle.plugin");
111            let name = format!("{plugin_id}:{artifact_id}");
112
113            let name_range = find_plugin_name_range(line, line_u32, &plugin_id);
114            let version_range = find_plugin_version_range(line, line_u32, &version);
115
116            dependencies.push(GradleDependency {
117                group_id: plugin_id,
118                artifact_id,
119                name,
120                name_range,
121                version_req: Some(version),
122                version_range: Some(version_range),
123                configuration: "plugin".to_string(),
124            });
125        }
126    }
127
128    Ok(GradleParseResult {
129        dependencies,
130        uri: uri.clone(),
131    })
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn make_uri(name: &str) -> Uri {
139        Uri::from_file_path(format!("/project/{name}")).unwrap()
140    }
141
142    #[test]
143    fn test_parse_groovy_plugin() {
144        let content = r#"pluginManagement {
145    plugins {
146        id "org.jetbrains.kotlin.jvm" version "2.1.10"
147        id 'com.google.devtools.ksp' version '2.1.10-1.0.31'
148    }
149}
150"#;
151        let result = parse_settings(content, &make_uri("settings.gradle")).unwrap();
152        assert_eq!(result.dependencies.len(), 2);
153
154        let dep = &result.dependencies[0];
155        assert_eq!(dep.group_id, "org.jetbrains.kotlin.jvm");
156        assert_eq!(dep.artifact_id, "org.jetbrains.kotlin.jvm.gradle.plugin");
157        assert_eq!(
158            dep.name,
159            "org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin"
160        );
161        assert_eq!(dep.version_req, Some("2.1.10".into()));
162        assert_eq!(dep.configuration, "plugin");
163
164        let dep2 = &result.dependencies[1];
165        assert_eq!(dep2.group_id, "com.google.devtools.ksp");
166        assert_eq!(dep2.version_req, Some("2.1.10-1.0.31".into()));
167    }
168
169    #[test]
170    fn test_parse_kotlin_dsl_plugin() {
171        let content = r#"pluginManagement {
172    plugins {
173        id("org.springframework.boot") version "3.2.0"
174    }
175}
176"#;
177        let result = parse_settings(content, &make_uri("settings.gradle.kts")).unwrap();
178        assert_eq!(result.dependencies.len(), 1);
179        assert_eq!(result.dependencies[0].group_id, "org.springframework.boot");
180        assert_eq!(result.dependencies[0].version_req, Some("3.2.0".into()));
181    }
182
183    #[test]
184    fn test_no_plugin_management_block() {
185        let content = "rootProject.name = \"my-project\"\n";
186        let result = parse_settings(content, &make_uri("settings.gradle")).unwrap();
187        assert!(result.dependencies.is_empty());
188    }
189
190    #[test]
191    fn test_plugin_without_version_skipped() {
192        let content = r#"pluginManagement {
193    plugins {
194        id "org.jetbrains.kotlin.jvm"
195    }
196}
197"#;
198        let result = parse_settings(content, &make_uri("settings.gradle")).unwrap();
199        assert!(result.dependencies.is_empty());
200    }
201
202    #[test]
203    fn test_position_tracking() {
204        let content = r#"pluginManagement {
205    plugins {
206        id "org.jetbrains.kotlin.jvm" version "2.1.10"
207    }
208}
209"#;
210        let result = parse_settings(content, &make_uri("settings.gradle")).unwrap();
211        assert_eq!(result.dependencies.len(), 1);
212        let dep = &result.dependencies[0];
213        assert_eq!(dep.name_range.start.line, 2);
214        assert!(dep.version_range.is_some());
215        let vr = dep.version_range.unwrap();
216        assert_eq!(vr.start.line, 2);
217    }
218
219    #[test]
220    fn test_empty_content() {
221        let result = parse_settings("", &make_uri("settings.gradle")).unwrap();
222        assert!(result.dependencies.is_empty());
223    }
224}