Skip to main content

provenant/parsers/
gradle_settings.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Gradle settings files (`settings.gradle`, `settings.gradle.kts`).
5//!
6//! A Gradle settings file defines the *structure* of a multi-project build: the
7//! set of subprojects (`include`/`includeFlat`) and the root project name. It is
8//! not itself a package, so this parser emits no `purl`; it records the declared
9//! subproject directories (and the root project name) into `extra_data` so
10//! `src/assembly/gradle_multiproject_merge.rs` can plan multi-project topology.
11//!
12//! The parser is intentionally thin and bounded: it reuses the Gradle build-file
13//! lexer to tokenize the settings script, extracts only *literal* `include` /
14//! `includeFlat` string arguments, and never executes Gradle. Non-literal
15//! arguments (variables, method calls, computed paths) are skipped rather than
16//! guessed at.
17//!
18//! # Project path normalization
19//!
20//! Gradle project paths are colon-delimited and rooted at the settings
21//! directory: `include ':libs:core'` declares a project at `libs/core`, and
22//! `include ':app'` a project at `app`. `includeFlat 'sibling'` declares a
23//! project in a sibling directory of the root, i.e. `../sibling`.
24//!
25//! # `projectDir` remaps
26//!
27//! Gradle lets a build relocate an included project's directory with a
28//! `project(':path').projectDir = <file>` statement in the settings script.
29//! Only the fully literal forms are recovered, and only when both the project
30//! path and the target directory are string literals:
31//!
32//! - `project(':app').projectDir = file('custom/app')`
33//! - `project(':app').projectDir = new File(settingsDir, 'custom/app')`
34//!   (also the Kotlin `File(rootDir, "custom/app")` form)
35//!
36//! The recovered override directory is always interpreted relative to the
37//! settings directory, matching how Gradle resolves `file(...)` and the
38//! `settingsDir`/`rootDir` bases in a settings script. Any remap whose project
39//! path or target is dynamic (a variable, string interpolation, a computed
40//! path, or a `File` form with an unrecognized base) is skipped rather than
41//! guessed at, and remaps are recorded only for the non-`includeFlat`
42//! (colon-path) project form. The overrides are stashed under
43//! `extra_data.project_dir_overrides` (default relative dir → override relative
44//! dir) so `src/assembly/topology.rs` can resolve the relocated member.
45
46use 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
58/// Parser for Gradle `settings.gradle` / `settings.gradle.kts` files.
59pub 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
136/// Extract the declared subproject directories, the optional root project name,
137/// and any literal `projectDir` remaps from a tokenized settings script.
138///
139/// The returned overrides map a project's default (include-derived) relative
140/// directory to its remapped relative directory; see the module docs for the
141/// bounded set of remap forms recovered.
142fn 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
199/// Parse a `project(':path').projectDir = <file>` remap starting at the
200/// `project` identifier token. Returns the project's default (include-derived)
201/// relative directory, the literal override directory, and the index to resume
202/// scanning from. Returns `None` for any non-literal or unrecognized form.
203fn parse_project_dir_remap(tokens: &[Tok], start: usize) -> Option<(String, String, usize)> {
204    // project ( "<:path>" ) projectDir = <file>
205    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    // A remap only applies to the ordinary colon-path project form; a project
223    // declared through `includeFlat` uses a different default directory that we
224    // deliberately do not attempt to reconcile here.
225    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
230/// Parse the right-hand side of a `projectDir` assignment into a single literal
231/// directory relative to the settings directory. Recognizes `file('literal')`
232/// and `[new] File(<settings-root base>, 'literal')`; anything else yields
233/// `None`. Returns the literal and the index just past the closing paren.
234fn parse_file_expression(tokens: &[Tok], start: usize) -> Option<(String, usize)> {
235    let mut cursor = start;
236    // Tolerate a leading `new` (Groovy/Java `new File(...)`).
237    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    // `File(base, 'literal')` carries a settings-root base before the literal;
255    // `file('literal')` carries only the literal. Require a recognized base for
256    // the `File` constructor so a base-relative path is not misread.
257    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
284/// Whether `ident` names the settings/root directory in a settings script, i.e.
285/// a base against which a literal `File(base, 'literal')` resolves to a
286/// settings-directory-relative path.
287fn is_settings_root_base(ident: &str) -> bool {
288    matches!(ident, "settingsDir" | "rootDir" | "rootProject.projectDir")
289}
290
291/// Collect the run of literal string arguments to an `include`/`includeFlat`
292/// call starting at `start`, tolerating an optional wrapping paren and
293/// comma separators. Stops at the first token that is not a string literal or a
294/// separator, so a non-literal argument (`include(someVar)`) contributes nothing
295/// rather than a guessed path. Returns the collected literals and the index to
296/// resume scanning from.
297fn 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
324/// Convert a declared Gradle project path to a directory relative to the
325/// settings file's directory. Returns `None` for an empty/degenerate path.
326fn 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        // `includeFlat 'name'` places the project in a sibling directory of the
334        // root project, i.e. `../name`.
335        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;