use std::collections::HashMap;
use std::path::Path;
use serde_json::Value as JsonValue;
use super::PackageParser;
use super::gradle::{Tok, lex};
use super::metadata::ParserMetadata;
use crate::models::{DatasourceId, PackageData, PackageType};
use crate::parser_warn as warn;
use crate::parsers::utils::{read_file_to_string, truncate_field};
pub struct GradleSettingsParser;
impl PackageParser for GradleSettingsParser {
const PACKAGE_TYPE: PackageType = PackageType::Maven;
fn metadata() -> Vec<ParserMetadata> {
vec![ParserMetadata {
description: "Gradle settings script (multi-project structure)",
file_patterns: &["**/settings.gradle", "**/settings.gradle.kts"],
package_type: "maven",
primary_language: "Java",
documentation_url: Some(
"https://docs.gradle.org/current/userguide/multi_project_builds.html",
),
}]
}
fn is_match(path: &Path) -> bool {
path.file_name().is_some_and(|name| {
let name = name.to_string_lossy();
name == "settings.gradle" || name == "settings.gradle.kts"
})
}
fn extract_packages(path: &Path) -> Vec<PackageData> {
let content = match read_file_to_string(path, None) {
Ok(content) => content,
Err(error) => {
warn!("Failed to read Gradle settings file {:?}: {}", path, error);
return vec![default_package_data()];
}
};
let tokens = lex(&content);
let (projects, root_project_name, project_dir_overrides) = extract_settings(&tokens);
let mut extra_data: HashMap<String, JsonValue> = HashMap::new();
if !projects.is_empty() {
extra_data.insert(
"projects".to_string(),
JsonValue::Array(projects.into_iter().map(JsonValue::String).collect()),
);
}
if let Some(root_project_name) = root_project_name {
extra_data.insert(
"root_project_name".to_string(),
JsonValue::String(root_project_name),
);
}
if !project_dir_overrides.is_empty() {
extra_data.insert(
"project_dir_overrides".to_string(),
JsonValue::Object(
project_dir_overrides
.into_iter()
.map(|(key, value)| (key, JsonValue::String(value)))
.collect(),
),
);
}
let mut package = default_package_data();
if !extra_data.is_empty() {
package.extra_data = Some(extra_data);
}
vec![package]
}
}
fn default_package_data() -> PackageData {
PackageData {
package_type: Some(GradleSettingsParser::PACKAGE_TYPE),
datasource_id: Some(DatasourceId::GradleSettings),
..Default::default()
}
}
fn extract_settings(tokens: &[Tok]) -> (Vec<String>, Option<String>, HashMap<String, String>) {
let mut projects: Vec<String> = Vec::new();
let mut root_project_name: Option<String> = None;
let mut project_dir_overrides: HashMap<String, String> = HashMap::new();
let mut i = 0;
while i < tokens.len() {
let Tok::Ident(name) = &tokens[i] else {
i += 1;
continue;
};
if name == "rootProject.name" {
let mut cursor = i + 1;
if tokens.get(cursor) == Some(&Tok::Equals) {
cursor += 1;
}
if let Some(Tok::Str(value)) = tokens.get(cursor) {
let trimmed = value.trim();
if !trimmed.is_empty() && root_project_name.is_none() {
root_project_name = Some(truncate_field(trimmed.to_string()));
}
}
i += 1;
continue;
}
if name == "include" || name == "includeFlat" {
let is_flat = name == "includeFlat";
let (literals, next) = collect_string_arguments(tokens, i + 1);
for literal in literals {
if let Some(dir) = project_path_to_relative_dir(&literal, is_flat)
&& !projects.contains(&dir)
{
projects.push(dir);
}
}
i = next;
continue;
}
if name == "project"
&& let Some((default_dir, override_dir, next)) = parse_project_dir_remap(tokens, i)
{
project_dir_overrides
.entry(default_dir)
.or_insert(override_dir);
i = next;
continue;
}
i += 1;
}
(projects, root_project_name, project_dir_overrides)
}
fn parse_project_dir_remap(tokens: &[Tok], start: usize) -> Option<(String, String, usize)> {
if tokens.get(start + 1) != Some(&Tok::OpenParen) {
return None;
}
let Some(Tok::Str(project_path)) = tokens.get(start + 2) else {
return None;
};
if tokens.get(start + 3) != Some(&Tok::CloseParen) {
return None;
}
match tokens.get(start + 4) {
Some(Tok::Ident(field)) if field == "projectDir" => {}
_ => return None,
}
if tokens.get(start + 5) != Some(&Tok::Equals) {
return None;
}
let default_dir = project_path_to_relative_dir(project_path, false)?;
let (override_dir, next) = parse_file_expression(tokens, start + 6)?;
Some((default_dir, override_dir, next))
}
fn parse_file_expression(tokens: &[Tok], start: usize) -> Option<(String, usize)> {
let mut cursor = start;
if matches!(tokens.get(cursor), Some(Tok::Ident(kw)) if kw == "new") {
cursor += 1;
}
let Some(Tok::Ident(func)) = tokens.get(cursor) else {
return None;
};
let is_file_ctor = func == "File";
let is_file_helper = func == "file";
if !is_file_ctor && !is_file_helper {
return None;
}
cursor += 1;
if tokens.get(cursor) != Some(&Tok::OpenParen) {
return None;
}
cursor += 1;
if is_file_ctor {
match tokens.get(cursor) {
Some(Tok::Ident(base)) if is_settings_root_base(base) => cursor += 1,
_ => return None,
}
if tokens.get(cursor) != Some(&Tok::Comma) {
return None;
}
cursor += 1;
}
let Some(Tok::Str(literal)) = tokens.get(cursor) else {
return None;
};
cursor += 1;
if tokens.get(cursor) != Some(&Tok::CloseParen) {
return None;
}
cursor += 1;
let trimmed = literal.trim().trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
Some((truncate_field(trimmed.to_string()), cursor))
}
fn is_settings_root_base(ident: &str) -> bool {
matches!(ident, "settingsDir" | "rootDir" | "rootProject.projectDir")
}
fn collect_string_arguments(tokens: &[Tok], start: usize) -> (Vec<String>, usize) {
let mut literals = Vec::new();
let mut i = start;
let mut saw_open_paren = false;
while i < tokens.len() {
match &tokens[i] {
Tok::OpenParen if !saw_open_paren && literals.is_empty() => {
saw_open_paren = true;
i += 1;
}
Tok::Comma => i += 1,
Tok::Str(value) => {
literals.push(value.clone());
i += 1;
}
Tok::CloseParen if saw_open_paren => {
i += 1;
break;
}
_ => break,
}
}
(literals, i)
}
fn project_path_to_relative_dir(project_path: &str, is_flat: bool) -> Option<String> {
let trimmed = project_path.trim().trim_start_matches(':');
if trimmed.is_empty() {
return None;
}
if is_flat {
return Some(truncate_field(format!("../{trimmed}")));
}
let segments: Vec<&str> = trimmed.split(':').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return None;
}
Some(truncate_field(segments.join("/")))
}
#[cfg(test)]
#[path = "gradle_settings_test.rs"]
mod tests;