Skip to content

Commit aec1dde

Browse files
authored
feat(gradle): add variable resolution and settings.gradle parsing (#67)
Resolve $var/${var} references in build.gradle/build.gradle.kts from gradle.properties files (walks parent directories with child-overrides-parent merge). Parse pluginManagement { plugins { } } blocks in settings.gradle for plugin dependency tracking with Maven coordinate convention.
1 parent e3e3903 commit aec1dde

File tree

6 files changed

+481
-9
lines changed

6 files changed

+481
-9
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Parses `[versions]`, `[libraries]` sections with `version.ref` resolution
1717
- Recognizes all Gradle configurations: implementation, api, compileOnly, runtimeOnly, testImplementation, etc.
1818
- Feature-gated registration in deps-lsp (`gradle`)
19+
- **Gradle variable resolution**`$var` and `${var}` in `build.gradle`/`build.gradle.kts` resolved from `gradle.properties` (walks parent directories)
20+
- **settings.gradle parsing** — Extract plugin dependencies from `pluginManagement { plugins { } }` blocks (Groovy and Kotlin DSL)
1921
- **Google Maven repository support** — Android packages (`androidx.*`, `com.google.firebase.*`, `com.google.android.*`, `com.android.*`) now resolve from Google Maven instead of Maven Central
2022

2123
### Changed

Cargo.lock

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/deps-gradle/src/ecosystem.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,13 @@ impl Ecosystem for GradleEcosystem {
165165
}
166166

167167
fn manifest_filenames(&self) -> &[&'static str] {
168-
&["libs.versions.toml", "build.gradle.kts", "build.gradle"]
168+
&[
169+
"libs.versions.toml",
170+
"build.gradle.kts",
171+
"build.gradle",
172+
"settings.gradle.kts",
173+
"settings.gradle",
174+
]
169175
}
170176

171177
fn lockfile_filenames(&self) -> &[&'static str] {
@@ -312,6 +318,8 @@ mod tests {
312318
assert!(eco.manifest_filenames().contains(&"libs.versions.toml"));
313319
assert!(eco.manifest_filenames().contains(&"build.gradle.kts"));
314320
assert!(eco.manifest_filenames().contains(&"build.gradle"));
321+
assert!(eco.manifest_filenames().contains(&"settings.gradle.kts"));
322+
assert!(eco.manifest_filenames().contains(&"settings.gradle"));
315323
}
316324

317325
#[test]

crates/deps-gradle/src/parser/mod.rs

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
pub mod catalog;
66
pub mod groovy;
77
pub mod kotlin;
8+
pub mod properties;
9+
pub mod settings;
810

911
use crate::error::Result;
1012
use crate::types::GradleDependency;
1113
use std::any::Any;
14+
use std::collections::HashMap;
1215
use tower_lsp_server::ls_types::{Position, Range, Uri};
1316

1417
pub use deps_core::lsp_helpers::LineOffsetTable;
@@ -18,20 +21,61 @@ pub struct GradleParseResult {
1821
pub uri: Uri,
1922
}
2023

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).
29+
pub 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.
40+
fn 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+
2151
pub fn parse_gradle(content: &str, uri: &Uri) -> Result<GradleParseResult> {
2252
let path = uri.path().to_string();
23-
if path.ends_with("libs.versions.toml") {
24-
catalog::parse_version_catalog(content, uri)
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)?
2557
} else if path.ends_with(".gradle.kts") {
26-
kotlin::parse_kotlin_dsl(content, uri)
58+
kotlin::parse_kotlin_dsl(content, uri)?
2759
} else if path.ends_with(".gradle") {
28-
groovy::parse_groovy_dsl(content, uri)
60+
groovy::parse_groovy_dsl(content, uri)?
2961
} else {
30-
Ok(GradleParseResult {
62+
return Ok(GradleParseResult {
3163
dependencies: vec![],
3264
uri: uri.clone(),
33-
})
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+
}
3476
}
77+
78+
Ok(result)
3579
}
3680

3781
impl deps_core::ParseResult for GradleParseResult {
@@ -136,12 +180,94 @@ mod tests {
136180
}
137181

138182
#[test]
139-
fn test_dispatch_unknown() {
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";
140185
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");
141201
let result = parse_gradle("", &uri).unwrap();
142202
assert!(result.dependencies.is_empty());
143203
}
144204

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+
145271
#[test]
146272
fn test_parse_result_trait() {
147273
use deps_core::ParseResult;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//! Parser for gradle.properties files.
2+
//!
3+
//! Provides key-value parsing and directory-walking lookup.
4+
5+
use std::collections::HashMap;
6+
use std::path::Path;
7+
8+
/// Parses a gradle.properties content into key-value pairs.
9+
///
10+
/// Lines starting with `#` or empty lines are ignored.
11+
/// Each line is split on the first `=`.
12+
pub fn parse_properties(content: &str) -> HashMap<String, String> {
13+
content
14+
.lines()
15+
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
16+
.filter_map(|l| l.split_once('='))
17+
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
18+
.collect()
19+
}
20+
21+
/// Finds and parses gradle.properties files by walking up from `start_dir`.
22+
///
23+
/// Merges properties from all levels, with child values overriding parent values.
24+
pub fn load_gradle_properties(start_dir: &Path) -> HashMap<String, String> {
25+
let mut result = HashMap::new();
26+
let mut chain = Vec::new();
27+
let mut dir = Some(start_dir);
28+
29+
while let Some(d) = dir {
30+
let props_file = d.join("gradle.properties");
31+
if props_file.exists() {
32+
chain.push(props_file);
33+
}
34+
dir = d.parent();
35+
}
36+
37+
// Apply from root to leaf so child values override parent
38+
for path in chain.into_iter().rev() {
39+
if let Ok(content) = std::fs::read_to_string(&path) {
40+
result.extend(parse_properties(&content));
41+
}
42+
}
43+
44+
result
45+
}
46+
47+
#[cfg(test)]
48+
mod tests {
49+
use super::*;
50+
51+
#[test]
52+
fn test_parse_basic() {
53+
let content = "kotlinVersion=2.1.10\nspringVersion=3.2.0\n";
54+
let props = parse_properties(content);
55+
assert_eq!(
56+
props.get("kotlinVersion").map(|s| s.as_str()),
57+
Some("2.1.10")
58+
);
59+
assert_eq!(
60+
props.get("springVersion").map(|s| s.as_str()),
61+
Some("3.2.0")
62+
);
63+
}
64+
65+
#[test]
66+
fn test_parse_ignores_comments() {
67+
let content = "# this is a comment\nkey=value\n";
68+
let props = parse_properties(content);
69+
assert_eq!(props.len(), 1);
70+
assert_eq!(props.get("key").map(|s| s.as_str()), Some("value"));
71+
}
72+
73+
#[test]
74+
fn test_parse_ignores_empty_lines() {
75+
let content = "\nkey=value\n\n";
76+
let props = parse_properties(content);
77+
assert_eq!(props.len(), 1);
78+
}
79+
80+
#[test]
81+
fn test_parse_trims_whitespace() {
82+
let content = " key = value \n";
83+
let props = parse_properties(content);
84+
assert_eq!(props.get("key").map(|s| s.as_str()), Some("value"));
85+
}
86+
87+
#[test]
88+
fn test_parse_value_with_equals() {
89+
// Only splits on the first '='
90+
let content = "url=https://example.com?a=b\n";
91+
let props = parse_properties(content);
92+
assert_eq!(
93+
props.get("url").map(|s| s.as_str()),
94+
Some("https://example.com?a=b")
95+
);
96+
}
97+
98+
#[test]
99+
fn test_parse_empty() {
100+
let props = parse_properties("");
101+
assert!(props.is_empty());
102+
}
103+
}

0 commit comments

Comments
 (0)