provenant/parsers/
gradle_settings.rs1use std::collections::HashMap;
47use std::path::Path;
48
49use serde_json::Value as JsonValue;
50
51use super::PackageParser;
52use super::gradle::{Tok, lex};
53use super::metadata::ParserMetadata;
54use crate::models::{DatasourceId, PackageData, PackageType};
55use crate::parser_warn as warn;
56use crate::parsers::utils::{read_file_to_string, truncate_field};
57
58pub struct GradleSettingsParser;
60
61impl PackageParser for GradleSettingsParser {
62 const PACKAGE_TYPE: PackageType = PackageType::Maven;
63
64 fn metadata() -> Vec<ParserMetadata> {
65 vec![ParserMetadata {
66 description: "Gradle settings script (multi-project structure)",
67 file_patterns: &["**/settings.gradle", "**/settings.gradle.kts"],
68 package_type: "maven",
69 primary_language: "Java",
70 documentation_url: Some(
71 "https://docs.gradle.org/current/userguide/multi_project_builds.html",
72 ),
73 }]
74 }
75
76 fn is_match(path: &Path) -> bool {
77 path.file_name().is_some_and(|name| {
78 let name = name.to_string_lossy();
79 name == "settings.gradle" || name == "settings.gradle.kts"
80 })
81 }
82
83 fn extract_packages(path: &Path) -> Vec<PackageData> {
84 let content = match read_file_to_string(path, None) {
85 Ok(content) => content,
86 Err(error) => {
87 warn!("Failed to read Gradle settings file {:?}: {}", path, error);
88 return vec![default_package_data()];
89 }
90 };
91
92 let tokens = lex(&content);
93 let (projects, root_project_name, project_dir_overrides) = extract_settings(&tokens);
94
95 let mut extra_data: HashMap<String, JsonValue> = HashMap::new();
96 if !projects.is_empty() {
97 extra_data.insert(
98 "projects".to_string(),
99 JsonValue::Array(projects.into_iter().map(JsonValue::String).collect()),
100 );
101 }
102 if let Some(root_project_name) = root_project_name {
103 extra_data.insert(
104 "root_project_name".to_string(),
105 JsonValue::String(root_project_name),
106 );
107 }
108 if !project_dir_overrides.is_empty() {
109 extra_data.insert(
110 "project_dir_overrides".to_string(),
111 JsonValue::Object(
112 project_dir_overrides
113 .into_iter()
114 .map(|(key, value)| (key, JsonValue::String(value)))
115 .collect(),
116 ),
117 );
118 }
119
120 let mut package = default_package_data();
121 if !extra_data.is_empty() {
122 package.extra_data = Some(extra_data);
123 }
124 vec![package]
125 }
126}
127
128fn default_package_data() -> PackageData {
129 PackageData {
130 package_type: Some(GradleSettingsParser::PACKAGE_TYPE),
131 datasource_id: Some(DatasourceId::GradleSettings),
132 ..Default::default()
133 }
134}
135
136fn extract_settings(tokens: &[Tok]) -> (Vec<String>, Option<String>, HashMap<String, String>) {
143 let mut projects: Vec<String> = Vec::new();
144 let mut root_project_name: Option<String> = None;
145 let mut project_dir_overrides: HashMap<String, String> = HashMap::new();
146
147 let mut i = 0;
148 while i < tokens.len() {
149 let Tok::Ident(name) = &tokens[i] else {
150 i += 1;
151 continue;
152 };
153
154 if name == "rootProject.name" {
155 let mut cursor = i + 1;
156 if tokens.get(cursor) == Some(&Tok::Equals) {
157 cursor += 1;
158 }
159 if let Some(Tok::Str(value)) = tokens.get(cursor) {
160 let trimmed = value.trim();
161 if !trimmed.is_empty() && root_project_name.is_none() {
162 root_project_name = Some(truncate_field(trimmed.to_string()));
163 }
164 }
165 i += 1;
166 continue;
167 }
168
169 if name == "include" || name == "includeFlat" {
170 let is_flat = name == "includeFlat";
171 let (literals, next) = collect_string_arguments(tokens, i + 1);
172 for literal in literals {
173 if let Some(dir) = project_path_to_relative_dir(&literal, is_flat)
174 && !projects.contains(&dir)
175 {
176 projects.push(dir);
177 }
178 }
179 i = next;
180 continue;
181 }
182
183 if name == "project"
184 && let Some((default_dir, override_dir, next)) = parse_project_dir_remap(tokens, i)
185 {
186 project_dir_overrides
187 .entry(default_dir)
188 .or_insert(override_dir);
189 i = next;
190 continue;
191 }
192
193 i += 1;
194 }
195
196 (projects, root_project_name, project_dir_overrides)
197}
198
199fn parse_project_dir_remap(tokens: &[Tok], start: usize) -> Option<(String, String, usize)> {
204 if tokens.get(start + 1) != Some(&Tok::OpenParen) {
206 return None;
207 }
208 let Some(Tok::Str(project_path)) = tokens.get(start + 2) else {
209 return None;
210 };
211 if tokens.get(start + 3) != Some(&Tok::CloseParen) {
212 return None;
213 }
214 match tokens.get(start + 4) {
215 Some(Tok::Ident(field)) if field == "projectDir" => {}
216 _ => return None,
217 }
218 if tokens.get(start + 5) != Some(&Tok::Equals) {
219 return None;
220 }
221
222 let default_dir = project_path_to_relative_dir(project_path, false)?;
226 let (override_dir, next) = parse_file_expression(tokens, start + 6)?;
227 Some((default_dir, override_dir, next))
228}
229
230fn parse_file_expression(tokens: &[Tok], start: usize) -> Option<(String, usize)> {
235 let mut cursor = start;
236 if matches!(tokens.get(cursor), Some(Tok::Ident(kw)) if kw == "new") {
238 cursor += 1;
239 }
240 let Some(Tok::Ident(func)) = tokens.get(cursor) else {
241 return None;
242 };
243 let is_file_ctor = func == "File";
244 let is_file_helper = func == "file";
245 if !is_file_ctor && !is_file_helper {
246 return None;
247 }
248 cursor += 1;
249 if tokens.get(cursor) != Some(&Tok::OpenParen) {
250 return None;
251 }
252 cursor += 1;
253
254 if is_file_ctor {
258 match tokens.get(cursor) {
259 Some(Tok::Ident(base)) if is_settings_root_base(base) => cursor += 1,
260 _ => return None,
261 }
262 if tokens.get(cursor) != Some(&Tok::Comma) {
263 return None;
264 }
265 cursor += 1;
266 }
267
268 let Some(Tok::Str(literal)) = tokens.get(cursor) else {
269 return None;
270 };
271 cursor += 1;
272 if tokens.get(cursor) != Some(&Tok::CloseParen) {
273 return None;
274 }
275 cursor += 1;
276
277 let trimmed = literal.trim().trim_end_matches('/');
278 if trimmed.is_empty() {
279 return None;
280 }
281 Some((truncate_field(trimmed.to_string()), cursor))
282}
283
284fn is_settings_root_base(ident: &str) -> bool {
288 matches!(ident, "settingsDir" | "rootDir" | "rootProject.projectDir")
289}
290
291fn collect_string_arguments(tokens: &[Tok], start: usize) -> (Vec<String>, usize) {
298 let mut literals = Vec::new();
299 let mut i = start;
300 let mut saw_open_paren = false;
301
302 while i < tokens.len() {
303 match &tokens[i] {
304 Tok::OpenParen if !saw_open_paren && literals.is_empty() => {
305 saw_open_paren = true;
306 i += 1;
307 }
308 Tok::Comma => i += 1,
309 Tok::Str(value) => {
310 literals.push(value.clone());
311 i += 1;
312 }
313 Tok::CloseParen if saw_open_paren => {
314 i += 1;
315 break;
316 }
317 _ => break,
318 }
319 }
320
321 (literals, i)
322}
323
324fn project_path_to_relative_dir(project_path: &str, is_flat: bool) -> Option<String> {
327 let trimmed = project_path.trim().trim_start_matches(':');
328 if trimmed.is_empty() {
329 return None;
330 }
331
332 if is_flat {
333 return Some(truncate_field(format!("../{trimmed}")));
336 }
337
338 let segments: Vec<&str> = trimmed.split(':').filter(|s| !s.is_empty()).collect();
339 if segments.is_empty() {
340 return None;
341 }
342 Some(truncate_field(segments.join("/")))
343}
344
345#[cfg(test)]
346#[path = "gradle_settings_test.rs"]
347mod tests;