Skip to main content

provenant/parsers/
gradle.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for Gradle build files (Groovy and Kotlin DSL).
8//!
9//! Extracts dependencies from Gradle build scripts using a custom token-based
10//! lexer and recursive descent parser supporting both Groovy and Kotlin syntax.
11//!
12//! # Supported Formats
13//! - build.gradle (Groovy DSL)
14//! - build.gradle.kts (Kotlin DSL)
15//!
16//! # Key Features
17//! - Token-based lexer for Gradle syntax parsing (not full language parser)
18//! - Support for multiple dependency declaration styles
19//! - Dependency scope tracking (implementation, testImplementation, etc.)
20//! - Project dependency references and platform dependencies
21//! - Version interpolation and constraint parsing
22//! - Package URL (purl) generation for Maven packages
23//!
24//! # Implementation Notes
25//! - Custom 870-line lexer instead of external parser (smaller binary, easier maintenance)
26//! - Supports Groovy and Kotlin syntax variations
27//! - Graceful error handling with `warn!()` logs
28//! - Direct dependency tracking (all in build file are direct)
29
30use std::collections::{HashMap, HashSet};
31use std::path::{Path, PathBuf};
32use std::sync::{Mutex, OnceLock};
33
34use crate::parser_warn as warn;
35use crate::parsers::utils::{
36    CappedIterExt, MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string, truncate_field,
37};
38
39const MAX_RECURSION_DEPTH: usize = 50;
40use packageurl::PackageUrl;
41use serde_json::json;
42
43use super::metadata::ParserMetadata;
44use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
45use crate::parsers::PackageParser;
46
47use super::license_normalization::{
48    DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
49    normalize_spdx_expression,
50};
51
52/// Parses Gradle build files (build.gradle, build.gradle.kts).
53///
54/// Extracts dependencies from Gradle build scripts using a custom
55/// token-based lexer and recursive descent parser. Supports both
56/// Groovy and Kotlin DSL syntax.
57///
58/// # Supported Patterns
59/// - String notation: `implementation 'group:name:version'`
60/// - Named parameters: `implementation group: 'x', name: 'y', version: 'z'`
61/// - Map format: `implementation([group: 'x', name: 'y'])`
62/// - Nested functions: `implementation(enforcedPlatform("..."))`
63/// - Project references: `implementation(project(":module"))`
64/// - String interpolation: `implementation("group:name:${version}")`
65///
66/// # Implementation
67/// Uses a custom token-based lexer (870 lines) instead of tree-sitter for:
68/// - Lighter binary size (no external parser dependencies)
69/// - Easier maintenance for DSL-specific quirks
70/// - Better error messages for malformed input
71///
72/// Typical usage is calling `GradleParser::extract_first_package()` on a
73/// `build.gradle` or `build.gradle.kts` file and then inspecting the returned
74/// dependency list.
75pub struct GradleParser;
76
77impl PackageParser for GradleParser {
78    const PACKAGE_TYPE: PackageType = PackageType::Maven;
79
80    fn metadata() -> Vec<ParserMetadata> {
81        vec![ParserMetadata {
82            description: "Gradle build script",
83            file_patterns: &["**/build.gradle", "**/build.gradle.kts"],
84            package_type: "maven",
85            primary_language: "Java",
86            documentation_url: Some("https://gradle.org/"),
87        }]
88    }
89
90    fn is_match(path: &Path) -> bool {
91        path.file_name().is_some_and(|name| {
92            let name_str = name.to_string_lossy();
93            name_str == "build.gradle" || name_str == "build.gradle.kts"
94        })
95    }
96
97    fn extract_packages(path: &Path) -> Vec<PackageData> {
98        let content = match read_file_to_string(path, None) {
99            Ok(c) => c,
100            Err(e) => {
101                warn!("Failed to read {:?}: {}", path, e);
102                return vec![default_package_data()];
103            }
104        };
105
106        let tokens = lex(&content);
107        let dependencies = extract_dependencies_with_context(path, &content, &tokens);
108        let (
109            extracted_license_statement,
110            declared_license_expression,
111            declared_license_expression_spdx,
112            license_detections,
113        ) = extract_gradle_license_metadata(&tokens);
114
115        vec![PackageData {
116            package_type: Some(Self::PACKAGE_TYPE),
117            namespace: None,
118            name: None,
119            version: None,
120            qualifiers: None,
121            subpath: None,
122            primary_language: None,
123            description: None,
124            release_date: None,
125            parties: Vec::new(),
126            keywords: Vec::new(),
127            homepage_url: None,
128            download_url: None,
129            size: None,
130            sha1: None,
131            md5: None,
132            sha256: None,
133            sha512: None,
134            bug_tracking_url: None,
135            code_view_url: None,
136            vcs_url: None,
137            copyright: None,
138            holder: None,
139            declared_license_expression,
140            declared_license_expression_spdx,
141            license_detections,
142            other_license_expression: None,
143            other_license_expression_spdx: None,
144            other_license_detections: Vec::new(),
145            extracted_license_statement,
146            notice_text: None,
147            source_packages: Vec::new(),
148            file_references: Vec::new(),
149            extra_data: None,
150            dependencies,
151            repository_homepage_url: None,
152            repository_download_url: None,
153            api_data_url: None,
154            datasource_id: Some(DatasourceId::BuildGradle),
155            purl: None,
156            is_private: false,
157            is_virtual: false,
158        }]
159    }
160}
161
162fn default_package_data() -> PackageData {
163    PackageData {
164        package_type: Some(GradleParser::PACKAGE_TYPE),
165        datasource_id: Some(DatasourceId::BuildGradle),
166        ..Default::default()
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Lexer
172// ---------------------------------------------------------------------------
173
174#[derive(Debug, Clone, PartialEq)]
175enum Tok {
176    Ident(String),
177    Str(String),
178    MalformedStr(String),
179    OpenParen,
180    CloseParen,
181    OpenBracket,
182    CloseBracket,
183    OpenBrace,
184    CloseBrace,
185    Colon,
186    Comma,
187    Equals,
188}
189
190fn lex(input: &str) -> Vec<Tok> {
191    let chars: Vec<char> = input.chars().collect();
192    let len = chars.len();
193    let mut i = 0;
194    let mut tokens = Vec::new();
195
196    while i < len {
197        if tokens.len() >= MAX_ITERATION_COUNT {
198            warn!(
199                "Lexer exceeded MAX_ITERATION_COUNT ({}) tokens, stopping",
200                MAX_ITERATION_COUNT
201            );
202            break;
203        }
204        let c = chars[i];
205
206        if c == '/' && i + 1 < len && chars[i + 1] == '/' {
207            while i < len && chars[i] != '\n' {
208                i += 1;
209            }
210            continue;
211        }
212
213        if c == '/' && i + 1 < len && chars[i + 1] == '*' {
214            i += 2;
215            while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
216                i += 1;
217            }
218            i += 2;
219            continue;
220        }
221
222        if c.is_whitespace() {
223            i += 1;
224            continue;
225        }
226
227        if c == '\'' {
228            i += 1;
229            let start = i;
230            while i < len && chars[i] != '\'' && chars[i] != '\n' {
231                i += 1;
232            }
233            let val: String = chars[start..i].iter().collect();
234            let val = truncate_field(val);
235            if i < len && chars[i] == '\'' {
236                tokens.push(Tok::Str(val));
237                i += 1;
238            } else {
239                tokens.push(Tok::MalformedStr(val));
240            }
241            continue;
242        }
243
244        if c == '"' {
245            i += 1;
246            let start = i;
247            while i < len && chars[i] != '"' && chars[i] != '\n' {
248                if chars[i] == '\\' && i + 1 < len {
249                    i += 2;
250                } else {
251                    i += 1;
252                }
253            }
254            let val: String = chars[start..i].iter().collect();
255            let val = truncate_field(val);
256            if i < len && chars[i] == '"' {
257                tokens.push(Tok::Str(val));
258                i += 1;
259            } else {
260                tokens.push(Tok::MalformedStr(val));
261            }
262            continue;
263        }
264
265        match c {
266            '(' => {
267                tokens.push(Tok::OpenParen);
268                i += 1;
269            }
270            ')' => {
271                tokens.push(Tok::CloseParen);
272                i += 1;
273            }
274            '[' => {
275                tokens.push(Tok::OpenBracket);
276                i += 1;
277            }
278            ']' => {
279                tokens.push(Tok::CloseBracket);
280                i += 1;
281            }
282            '{' => {
283                tokens.push(Tok::OpenBrace);
284                i += 1;
285            }
286            '}' => {
287                tokens.push(Tok::CloseBrace);
288                i += 1;
289            }
290            ':' => {
291                tokens.push(Tok::Colon);
292                i += 1;
293            }
294            ',' => {
295                tokens.push(Tok::Comma);
296                i += 1;
297            }
298            '=' => {
299                tokens.push(Tok::Equals);
300                i += 1;
301            }
302            _ if is_ident_start(c) => {
303                let start = i;
304                while i < len && is_ident_char(chars[i]) {
305                    i += 1;
306                }
307                let val: String = chars[start..i].iter().collect();
308                tokens.push(Tok::Ident(truncate_field(val)));
309            }
310            _ => {
311                i += 1;
312            }
313        }
314    }
315
316    tokens
317}
318
319fn is_ident_start(c: char) -> bool {
320    c.is_ascii_alphanumeric() || c == '_' || c == '-'
321}
322
323fn is_ident_char(c: char) -> bool {
324    c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '$'
325}
326
327// ---------------------------------------------------------------------------
328// Dependency block extraction
329// ---------------------------------------------------------------------------
330
331fn find_dependency_blocks(tokens: &[Tok]) -> Vec<Vec<Tok>> {
332    let mut blocks = Vec::new();
333    let mut i = 0;
334
335    while i < tokens.len() {
336        if let Tok::Ident(ref name) = tokens[i]
337            && name == "dependencies"
338            && i + 1 < tokens.len()
339            && tokens[i + 1] == Tok::OpenBrace
340        {
341            i += 2;
342            let mut depth = 1;
343            let start = i;
344            while i < tokens.len() && depth > 0 {
345                match &tokens[i] {
346                    Tok::OpenBrace => {
347                        depth += 1;
348                        if depth > MAX_RECURSION_DEPTH {
349                            warn!(
350                                "Gradle parser: nesting depth exceeded {} in find_dependency_blocks",
351                                MAX_RECURSION_DEPTH
352                            );
353                            break;
354                        }
355                    }
356                    Tok::CloseBrace => depth -= 1,
357                    _ => {}
358                }
359                if depth > 0 {
360                    i += 1;
361                }
362            }
363            blocks.push(tokens[start..i].to_vec());
364            if i < tokens.len() {
365                i += 1;
366            }
367            continue;
368        }
369        i += 1;
370    }
371
372    blocks
373}
374
375// ---------------------------------------------------------------------------
376// Dependency extraction from blocks
377// ---------------------------------------------------------------------------
378
379#[derive(Debug, Clone, PartialEq, Eq, Hash)]
380struct RawDep {
381    namespace: String,
382    name: String,
383    version: String,
384    scope: String,
385    catalog_alias: Option<String>,
386    symbolic_ref: Option<String>,
387    project_path: Option<String>,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
391enum BuildSrcExpr {
392    Literal(String),
393    Ref(String),
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
397struct BuildSrcConst {
398    scope: String,
399    expr: BuildSrcExpr,
400}
401
402type BuildSrcConstMap = HashMap<String, BuildSrcConst>;
403type BuildSrcCache = HashMap<PathBuf, Option<BuildSrcConstMap>>;
404
405static BUILD_SRC_CONSTANT_CACHE: OnceLock<Mutex<BuildSrcCache>> = OnceLock::new();
406
407fn extract_dependencies_with_context(
408    path: &Path,
409    content: &str,
410    tokens: &[Tok],
411) -> Vec<Dependency> {
412    let mut raw_dependencies = extract_raw_dependencies(tokens);
413    resolve_gradle_script_interpolations(path, content, &mut raw_dependencies);
414    resolve_gradle_buildsrc_symbolic_refs(path, &mut raw_dependencies);
415    let mut dependencies = raw_dependencies
416        .iter()
417        .filter_map(create_dependency)
418        .collect::<Vec<_>>();
419    resolve_gradle_version_catalog_aliases(path, &mut dependencies);
420    dependencies
421}
422
423#[cfg(test)]
424fn extract_dependencies(tokens: &[Tok]) -> Vec<Dependency> {
425    extract_raw_dependencies(tokens)
426        .iter()
427        .filter_map(create_dependency)
428        .collect()
429}
430
431fn extract_raw_dependencies(tokens: &[Tok]) -> Vec<RawDep> {
432    let blocks = find_dependency_blocks(tokens);
433    let mut dependencies = Vec::new();
434
435    for block in blocks {
436        let parsed = parse_block(&block);
437        let limit = capped_iteration_limit(parsed.len(), "gradle dependency block");
438        for rd in parsed.into_iter().take(limit) {
439            dependencies.push(rd);
440        }
441    }
442
443    dependencies
444}
445
446fn parse_block(tokens: &[Tok]) -> Vec<RawDep> {
447    let mut deps = Vec::new();
448    let mut i = 0;
449    let mut iterations = 0;
450
451    while i < tokens.len() {
452        iterations += 1;
453        if iterations > MAX_ITERATION_COUNT {
454            warn!(
455                "parse_block exceeded MAX_ITERATION_COUNT ({}) iterations, stopping",
456                MAX_ITERATION_COUNT
457            );
458            break;
459        }
460
461        if let Some(next_index) = parse_control_flow_block(tokens, i, &mut deps) {
462            i = next_index;
463            continue;
464        }
465
466        // Skip nested blocks (closures like `{ transitive = true }`)
467        if tokens[i] == Tok::OpenBrace {
468            let mut depth = 1;
469            i += 1;
470            while i < tokens.len() && depth > 0 {
471                match &tokens[i] {
472                    Tok::OpenBrace => {
473                        depth += 1;
474                        if depth > MAX_RECURSION_DEPTH {
475                            warn!(
476                                "Gradle parser: nesting depth exceeded {} in parse_block",
477                                MAX_RECURSION_DEPTH
478                            );
479                            break;
480                        }
481                    }
482                    Tok::CloseBrace => depth -= 1,
483                    _ => {}
484                }
485                i += 1;
486            }
487            continue;
488        }
489
490        if let Tok::Str(scope_name) = &tokens[i]
491            && i + 1 < tokens.len()
492            && tokens[i + 1] == Tok::OpenParen
493            && let Some(end) = find_matching_paren(tokens, i + 1)
494        {
495            let inner = &tokens[i + 2..end];
496            parse_paren_content(scope_name, inner, &mut deps);
497            i = end + 1;
498            continue;
499        }
500
501        let scope_name = match &tokens[i] {
502            Tok::Ident(name) => name.clone(),
503            _ => {
504                i += 1;
505                continue;
506            }
507        };
508
509        if is_skip_keyword(&scope_name) {
510            i += 1;
511            continue;
512        }
513
514        let next = i + 1;
515
516        // PATTERN: scope ( ... )  — parenthesized dependency
517        if next < tokens.len() && tokens[next] == Tok::OpenParen {
518            let paren_end = find_matching_paren(tokens, next);
519            if let Some(end) = paren_end {
520                let inner = &tokens[next + 1..end];
521                parse_paren_content(&scope_name, inner, &mut deps);
522                i = end + 1;
523                continue;
524            }
525        }
526
527        // PATTERN: scope group: ..., name: ..., version: ... (named params without parens)
528        if next < tokens.len()
529            && let Tok::Ident(ref label) = tokens[next]
530            && label == "group"
531            && next + 1 < tokens.len()
532            && tokens[next + 1] == Tok::Colon
533            && let Some((rd, consumed)) = parse_named_params(&scope_name, &tokens[next..])
534        {
535            deps.push(rd);
536            i = next + consumed;
537            continue;
538        }
539
540        // PATTERN: scope 'string:notation' (string notation)
541        if next < tokens.len()
542            && matches!(
543                tokens.get(next),
544                Some(Tok::Str(_)) | Some(Tok::MalformedStr(_))
545            )
546        {
547            let (val, is_malformed) = match &tokens[next] {
548                Tok::Str(val) => (val.as_str(), false),
549                Tok::MalformedStr(val) => (val.as_str(), true),
550                _ => unreachable!(),
551            };
552
553            if !val.contains(':') {
554                i = next + 1;
555                continue;
556            }
557
558            if val.chars().next().is_some_and(|c| c.is_whitespace()) {
559                break;
560            }
561
562            // `scope 'str', { closure }` → skip (unparenthesized call with trailing closure)
563            if next + 1 < tokens.len()
564                && tokens[next + 1] == Tok::Comma
565                && next + 2 < tokens.len()
566                && tokens[next + 2] == Tok::OpenBrace
567            {
568                i = next + 1;
569                continue;
570            }
571            let is_multi = i + 2 < tokens.len()
572                && tokens[next + 1] == Tok::Comma
573                && matches!(tokens.get(next + 2), Some(Tok::Str(_)));
574            let effective_scope = if is_multi { "" } else { &scope_name };
575            let rd = parse_colon_string(val, effective_scope);
576            deps.push(rd);
577            if is_malformed {
578                break;
579            }
580            i = next + 1;
581            while i < tokens.len() && tokens[i] == Tok::Comma {
582                i += 1;
583                if i < tokens.len()
584                    && let Tok::Str(ref v2) = tokens[i]
585                    && v2.contains(':')
586                {
587                    deps.push(parse_colon_string(v2, ""));
588                    i += 1;
589                    continue;
590                }
591                break;
592            }
593            continue;
594        }
595
596        // PATTERN: scope libs.foo.bar (version catalog alias)
597        // Keep TOML-backed `libs.*` aliases for later version-catalog resolution,
598        // but ignore other unresolved dotted identifiers such as `dependencies.*`
599        // or arbitrary constants like `Deps.AndroidX.core`.
600        if next < tokens.len()
601            && let Tok::Ident(ref val) = tokens[next]
602            && val.starts_with("libs.")
603            && let Some(last_seg) = val.rsplit('.').next()
604            && !last_seg.is_empty()
605        {
606            deps.push(RawDep {
607                namespace: String::new(),
608                name: truncate_field(last_seg.to_string()),
609                version: String::new(),
610                scope: truncate_field(scope_name.clone()),
611                catalog_alias: val
612                    .strip_prefix("libs.")
613                    .map(|alias| truncate_field(alias.to_string())),
614                symbolic_ref: None,
615                project_path: None,
616            });
617            i = next + 1;
618            continue;
619        }
620
621        if next < tokens.len()
622            && let Tok::Ident(ref val) = tokens[next]
623            && val.contains('.')
624        {
625            deps.push(parse_symbolic_ref(&scope_name, val));
626            i = next + 1;
627            continue;
628        }
629
630        // PATTERN: scope project(':module') — project reference without parens
631        if next < tokens.len()
632            && let Tok::Ident(ref name) = tokens[next]
633            && name == "project"
634            && next + 1 < tokens.len()
635            && tokens[next + 1] == Tok::OpenParen
636            && let Some(end) = find_matching_paren(tokens, next + 1)
637        {
638            let inner = &tokens[next + 2..end];
639            if let Some(rd) = parse_project_ref(inner, &scope_name) {
640                deps.push(rd);
641            }
642            i = end + 1;
643            continue;
644        }
645
646        i += 1;
647    }
648
649    deps
650}
651
652fn parse_control_flow_block(tokens: &[Tok], start: usize, deps: &mut Vec<RawDep>) -> Option<usize> {
653    let Tok::Ident(keyword) = tokens.get(start)? else {
654        return None;
655    };
656
657    if keyword != "if" && keyword != "else" {
658        return None;
659    }
660
661    let mut block_start = start + 1;
662    if keyword == "if" {
663        if tokens.get(block_start) != Some(&Tok::OpenParen) {
664            return None;
665        }
666        let cond_end = find_matching_paren(tokens, block_start)?;
667        block_start = cond_end + 1;
668    } else if let Some(Tok::Ident(next)) = tokens.get(block_start)
669        && next == "if"
670    {
671        return parse_control_flow_block(tokens, block_start, deps);
672    }
673
674    if tokens.get(block_start) != Some(&Tok::OpenBrace) {
675        return None;
676    }
677
678    let block_end = find_matching_brace(tokens, block_start)?;
679    deps.extend(parse_block(&tokens[block_start + 1..block_end]));
680    Some(block_end + 1)
681}
682
683fn is_skip_keyword(name: &str) -> bool {
684    matches!(
685        name,
686        "plugins"
687            | "apply"
688            | "ext"
689            | "configurations"
690            | "repositories"
691            | "subprojects"
692            | "allprojects"
693            | "buildscript"
694            | "pluginManager"
695            | "publishing"
696            | "sourceSets"
697            | "tasks"
698            | "task"
699    )
700}
701
702fn parse_paren_content(scope: &str, tokens: &[Tok], deps: &mut Vec<RawDep>) {
703    if tokens.is_empty() {
704        return;
705    }
706
707    // Check for bracket-enclosed maps: [group: ..., name: ..., version: ...]
708    if tokens[0] == Tok::OpenBracket {
709        parse_bracket_maps(tokens, deps);
710        return;
711    }
712
713    // Check for named parameters: group: 'x' or group = "x"
714    if let Some(Tok::Ident(label)) = tokens.first()
715        && label == "group"
716        && tokens.len() > 1
717        && tokens[1] == Tok::Colon
718    {
719        if let Some((rd, _)) = parse_named_params("", tokens) {
720            deps.push(rd);
721        }
722        return;
723    }
724
725    // Check for nested function call or project reference
726    if let Some(Tok::Ident(inner_fn)) = tokens.first()
727        && tokens.len() > 1
728        && tokens[1] == Tok::OpenParen
729    {
730        if inner_fn == "project" {
731            if let Some(end) = find_matching_paren(tokens, 1) {
732                let inner = &tokens[2..end];
733                if let Some(rd) = parse_project_ref(inner, scope) {
734                    deps.push(rd);
735                }
736            }
737            return;
738        }
739
740        if let Some(end) = find_matching_paren(tokens, 1) {
741            let inner = &tokens[2..end];
742            if let Some(Tok::Str(val)) = inner.first()
743                && val.contains(':')
744            {
745                deps.push(parse_colon_string(val, inner_fn));
746                return;
747            }
748
749            if let Some(Tok::Ident(val)) = inner.first()
750                && val.contains('.')
751            {
752                deps.push(parse_symbolic_ref(inner_fn, val));
753                return;
754            }
755        }
756    }
757
758    if let Some(Tok::Ident(val)) = tokens.first()
759        && val.contains('.')
760    {
761        deps.push(parse_symbolic_ref(scope, val));
762        return;
763    }
764
765    // Simple string: ("g:n:v")
766    if let Some(Tok::Str(val)) = tokens.first()
767        && val.contains(':')
768    {
769        deps.push(parse_colon_string(val, scope));
770    }
771}
772
773fn parse_bracket_maps(tokens: &[Tok], deps: &mut Vec<RawDep>) {
774    let mut i = 0;
775    while i < tokens.len() {
776        if tokens[i] == Tok::OpenBracket
777            && let Some(end) = find_matching_bracket(tokens, i)
778        {
779            let map_tokens = &tokens[i + 1..end];
780            if let Some(rd) = parse_map_entries(map_tokens)
781                && !contains_equivalent_map_dep(deps, &rd)
782            {
783                deps.push(rd);
784            }
785            i = end + 1;
786            continue;
787        }
788        i += 1;
789    }
790}
791
792fn contains_equivalent_map_dep(existing: &[RawDep], candidate: &RawDep) -> bool {
793    existing.iter().any(|dep| {
794        dep.name == candidate.name
795            && dep.version == candidate.version
796            && dep.scope == candidate.scope
797            && (dep.namespace == candidate.namespace
798                || dep.namespace.is_empty()
799                || candidate.namespace.is_empty())
800    })
801}
802
803fn parse_map_entries(tokens: &[Tok]) -> Option<RawDep> {
804    let mut name = String::new();
805    let mut version = String::new();
806    let mut i = 0;
807
808    while i < tokens.len() {
809        if let Tok::Ident(ref label) = tokens[i]
810            && i + 2 < tokens.len()
811            && tokens[i + 1] == Tok::Colon
812            && let Tok::Str(ref val) = tokens[i + 2]
813        {
814            match label.as_str() {
815                "name" => name = truncate_field(val.clone()),
816                "version" => version = truncate_field(val.clone()),
817                _ => {}
818            }
819            i += 3;
820            if i < tokens.len() && tokens[i] == Tok::Comma {
821                i += 1;
822            }
823            continue;
824        }
825        i += 1;
826    }
827
828    if name.is_empty() {
829        return None;
830    }
831
832    Some(RawDep {
833        namespace: String::new(),
834        name,
835        version,
836        scope: String::new(),
837        catalog_alias: None,
838        symbolic_ref: None,
839        project_path: None,
840    })
841}
842
843fn parse_named_params(scope: &str, tokens: &[Tok]) -> Option<(RawDep, usize)> {
844    let mut group = String::new();
845    let mut name = String::new();
846    let mut version = String::new();
847    let mut i = 0;
848
849    while i < tokens.len() {
850        if let Tok::Ident(ref label) = tokens[i]
851            && i + 2 < tokens.len()
852            && tokens[i + 1] == Tok::Colon
853            && let Tok::Str(ref val) = tokens[i + 2]
854        {
855            match label.as_str() {
856                "group" => group = truncate_field(val.clone()),
857                "name" => name = truncate_field(val.clone()),
858                "version" => version = truncate_field(val.clone()),
859                _ => {}
860            }
861            i += 3;
862            if i < tokens.len() && tokens[i] == Tok::Comma {
863                i += 1;
864            }
865            continue;
866        }
867        break;
868    }
869
870    if name.is_empty() {
871        return None;
872    }
873
874    Some((
875        RawDep {
876            namespace: group,
877            name,
878            version,
879            scope: scope.to_string(),
880            catalog_alias: None,
881            symbolic_ref: None,
882            project_path: None,
883        },
884        i,
885    ))
886}
887
888fn parse_project_ref(tokens: &[Tok], scope: &str) -> Option<RawDep> {
889    if let Some(Tok::Str(val)) = tokens.first() {
890        let module_name = val.trim_start_matches(':');
891        let mut segments = module_name
892            .split(':')
893            .filter(|segment| !segment.is_empty())
894            .collect::<Vec<_>>();
895        let name = segments.pop().unwrap_or(module_name);
896        if name.is_empty() {
897            return None;
898        }
899        return Some(RawDep {
900            namespace: if segments.is_empty() {
901                String::new()
902            } else {
903                truncate_field(segments.join("/"))
904            },
905            name: truncate_field(name.to_string()),
906            version: String::new(),
907            scope: truncate_field(scope.to_string()),
908            catalog_alias: None,
909            symbolic_ref: None,
910            project_path: Some(truncate_field(module_name.to_string())),
911        });
912    }
913    None
914}
915
916fn parse_symbolic_ref(scope: &str, value: &str) -> RawDep {
917    RawDep {
918        namespace: String::new(),
919        name: String::new(),
920        version: String::new(),
921        scope: truncate_field(scope.to_string()),
922        catalog_alias: None,
923        symbolic_ref: Some(truncate_field(value.to_string())),
924        project_path: None,
925    }
926}
927
928fn parse_colon_string(val: &str, scope: &str) -> RawDep {
929    let parts: Vec<&str> = val.split(':').collect();
930    let (namespace, name, version) = match parts.len() {
931        n if n >= 4 => (
932            truncate_field(parts[0].to_string()),
933            truncate_field(parts[1].to_string()),
934            truncate_field(parts[2].to_string()),
935        ),
936        3 => (
937            truncate_field(parts[0].to_string()),
938            truncate_field(parts[1].to_string()),
939            truncate_field(parts[2].to_string()),
940        ),
941        2 => (
942            truncate_field(parts[0].to_string()),
943            truncate_field(parts[1].to_string()),
944            String::new(),
945        ),
946        _ => (
947            String::new(),
948            truncate_field(val.to_string()),
949            String::new(),
950        ),
951    };
952
953    RawDep {
954        namespace,
955        name,
956        version,
957        scope: truncate_field(scope.to_string()),
958        catalog_alias: None,
959        symbolic_ref: None,
960        project_path: None,
961    }
962}
963
964fn find_matching_paren(tokens: &[Tok], start: usize) -> Option<usize> {
965    if tokens.get(start) != Some(&Tok::OpenParen) {
966        return None;
967    }
968    let mut depth = 1;
969    let mut i = start + 1;
970    while i < tokens.len() && depth > 0 {
971        match &tokens[i] {
972            Tok::OpenParen => {
973                depth += 1;
974                if depth > MAX_RECURSION_DEPTH {
975                    warn!(
976                        "Gradle parser: nesting depth exceeded {} in find_matching_paren",
977                        MAX_RECURSION_DEPTH
978                    );
979                    break;
980                }
981            }
982            Tok::CloseParen => depth -= 1,
983            _ => {}
984        }
985        if depth == 0 {
986            return Some(i);
987        }
988        i += 1;
989    }
990    None
991}
992
993fn find_matching_bracket(tokens: &[Tok], start: usize) -> Option<usize> {
994    if tokens.get(start) != Some(&Tok::OpenBracket) {
995        return None;
996    }
997    let mut depth = 1;
998    let mut i = start + 1;
999    while i < tokens.len() && depth > 0 {
1000        match &tokens[i] {
1001            Tok::OpenBracket => {
1002                depth += 1;
1003                if depth > MAX_RECURSION_DEPTH {
1004                    warn!(
1005                        "Gradle parser: nesting depth exceeded {} in find_matching_bracket",
1006                        MAX_RECURSION_DEPTH
1007                    );
1008                    break;
1009                }
1010            }
1011            Tok::CloseBracket => depth -= 1,
1012            _ => {}
1013        }
1014        if depth == 0 {
1015            return Some(i);
1016        }
1017        i += 1;
1018    }
1019    None
1020}
1021
1022// ---------------------------------------------------------------------------
1023// Dependency construction
1024// ---------------------------------------------------------------------------
1025
1026fn create_dependency(raw: &RawDep) -> Option<Dependency> {
1027    let namespace = raw.namespace.as_str();
1028    let name = raw.name.as_str();
1029    let version = raw.version.as_str();
1030    let scope = raw.scope.as_str();
1031    if name.is_empty() {
1032        return None;
1033    }
1034
1035    let mut purl = PackageUrl::new("maven", name).ok()?;
1036
1037    if !namespace.is_empty() {
1038        purl.with_namespace(namespace).ok()?;
1039    }
1040
1041    if !version.is_empty() {
1042        purl.with_version(version).ok()?;
1043    }
1044
1045    let (is_runtime, is_optional) = classify_scope(scope);
1046    let is_pinned = !version.is_empty();
1047
1048    let purl_string = truncate_field(purl.to_string().replace("$", "%24").replace('\'', "%27"));
1049    let mut extra_data = std::collections::HashMap::new();
1050    if let Some(alias) = &raw.catalog_alias {
1051        extra_data.insert(
1052            "catalog_alias".to_string(),
1053            json!(truncate_field(alias.clone())),
1054        );
1055    }
1056    if let Some(project_path) = &raw.project_path {
1057        extra_data.insert(
1058            "project_path".to_string(),
1059            json!(truncate_field(project_path.clone())),
1060        );
1061    }
1062    if let Some(symbolic_ref) = &raw.symbolic_ref {
1063        extra_data.insert(
1064            "symbolic_ref".to_string(),
1065            json!(truncate_field(symbolic_ref.clone())),
1066        );
1067    }
1068
1069    Some(Dependency {
1070        purl: Some(purl_string),
1071        extracted_requirement: Some(truncate_field(version.to_string())),
1072        scope: Some(truncate_field(scope.to_string())),
1073        is_runtime: Some(is_runtime),
1074        is_optional: Some(is_optional),
1075        is_pinned: Some(is_pinned),
1076        is_direct: Some(true),
1077        resolved_package: None,
1078        extra_data: (!extra_data.is_empty()).then_some(extra_data),
1079    })
1080}
1081
1082fn classify_scope(scope: &str) -> (bool, bool) {
1083    let scope_lower = scope.to_lowercase();
1084
1085    if scope_lower.contains("test") {
1086        return (false, true);
1087    }
1088
1089    if matches!(
1090        scope_lower.as_str(),
1091        "compileonly" | "compileonlyapi" | "annotationprocessor" | "kapt" | "ksp"
1092    ) {
1093        return (false, false);
1094    }
1095
1096    (true, false)
1097}
1098
1099fn resolve_gradle_script_interpolations(
1100    path: &Path,
1101    content: &str,
1102    raw_dependencies: &mut [RawDep],
1103) {
1104    let properties = load_gradle_script_properties(path, content);
1105    if properties.is_empty() {
1106        return;
1107    }
1108
1109    for raw in raw_dependencies.iter_mut() {
1110        raw.namespace = interpolate_gradle_string(&raw.namespace, &properties);
1111        raw.name = interpolate_gradle_string(&raw.name, &properties);
1112        raw.version = interpolate_gradle_string(&raw.version, &properties);
1113    }
1114}
1115
1116fn load_gradle_script_properties(path: &Path, content: &str) -> HashMap<String, String> {
1117    let mut properties = load_gradle_properties(path);
1118
1119    let literal_assignment_patterns = [
1120        regex::Regex::new(
1121            r#"(?m)^\s*(?:const\s+)?(?:val|var|def)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=\s*['\"]([^'\"]+)['\"]"#,
1122        )
1123        .expect("valid regex"),
1124        regex::Regex::new(r#"(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['\"]([^'\"]+)['\"]"#)
1125            .expect("valid regex"),
1126    ];
1127
1128    for pattern in literal_assignment_patterns {
1129        for captures in pattern
1130            .captures_iter(content)
1131            .capped("gradle literal assignments")
1132        {
1133            let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1134                continue;
1135            };
1136            let Some(raw_value) = captures.get(2).map(|value| value.as_str()) else {
1137                continue;
1138            };
1139            let resolved = interpolate_gradle_string(raw_value, &properties);
1140            properties.insert(name.to_string(), resolved);
1141        }
1142    }
1143
1144    let delegated_project_property_pattern = regex::Regex::new(
1145        r#"(?m)^\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?\s+by\s+project\b"#,
1146    )
1147    .expect("valid regex");
1148
1149    for captures in delegated_project_property_pattern
1150        .captures_iter(content)
1151        .capped("gradle delegated project properties")
1152    {
1153        let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1154            continue;
1155        };
1156        if let Some(value) = properties.get(name).cloned() {
1157            properties.insert(name.to_string(), value);
1158        }
1159    }
1160
1161    properties
1162}
1163
1164fn load_gradle_properties(path: &Path) -> HashMap<String, String> {
1165    for ancestor in path.ancestors() {
1166        let gradle_properties = ancestor.join("gradle.properties");
1167        if !gradle_properties.is_file() {
1168            continue;
1169        }
1170
1171        let Ok(content) = read_file_to_string(&gradle_properties, None) else {
1172            continue;
1173        };
1174
1175        let mut properties = HashMap::new();
1176        for line in content.lines().capped("gradle.properties lines") {
1177            let trimmed = line.split('#').next().unwrap_or("").trim();
1178            if trimmed.is_empty() {
1179                continue;
1180            }
1181
1182            let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':'))
1183            else {
1184                continue;
1185            };
1186
1187            let key = key.trim();
1188            let value = value.trim();
1189            if key.is_empty() || value.is_empty() {
1190                continue;
1191            }
1192            properties.insert(key.to_string(), value.to_string());
1193        }
1194        return properties;
1195    }
1196
1197    HashMap::new()
1198}
1199
1200fn interpolate_gradle_string(value: &str, properties: &HashMap<String, String>) -> String {
1201    if !value.contains('$') {
1202        return truncate_field(value.to_string());
1203    }
1204
1205    let chars = value.chars().collect::<Vec<_>>();
1206    let mut rendered = String::new();
1207    let mut i = 0;
1208
1209    while i < chars.len() {
1210        if chars[i] != '$' {
1211            rendered.push(chars[i]);
1212            i += 1;
1213            continue;
1214        }
1215
1216        if i + 1 >= chars.len() {
1217            rendered.push(chars[i]);
1218            break;
1219        }
1220
1221        if chars[i + 1] == '{' {
1222            let start = i;
1223            i += 2;
1224            let mut reference = String::new();
1225            while i < chars.len() && chars[i] != '}' {
1226                reference.push(chars[i]);
1227                i += 1;
1228            }
1229            if i < chars.len() && chars[i] == '}' {
1230                i += 1;
1231            }
1232
1233            if let Some(resolved) = properties.get(reference.trim()) {
1234                rendered.push_str(resolved);
1235            } else {
1236                rendered.push_str(&value[start..i]);
1237            }
1238            continue;
1239        }
1240
1241        let start = i;
1242        i += 1;
1243        let mut reference = String::new();
1244        while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_') {
1245            reference.push(chars[i]);
1246            i += 1;
1247        }
1248
1249        if reference.is_empty() {
1250            rendered.push('$');
1251            continue;
1252        }
1253
1254        if let Some(resolved) = properties.get(reference.as_str()) {
1255            rendered.push_str(resolved);
1256        } else {
1257            rendered.push_str(&value[start..i]);
1258        }
1259    }
1260
1261    truncate_field(rendered)
1262}
1263
1264fn resolve_gradle_buildsrc_symbolic_refs(path: &Path, raw_dependencies: &mut [RawDep]) {
1265    let ancestor_build_src_dir = find_build_src_dir(path);
1266    let ancestor_constants = ancestor_build_src_dir
1267        .as_deref()
1268        .and_then(load_build_src_constants);
1269    let sibling_build_src_tiers = if ancestor_build_src_dir.is_none() {
1270        find_nearby_sibling_build_src_tiers(path)
1271    } else {
1272        Vec::new()
1273    };
1274
1275    for raw in raw_dependencies.iter_mut() {
1276        let Some(symbolic_ref) = raw.symbolic_ref.as_deref() else {
1277            continue;
1278        };
1279
1280        let resolved = ancestor_constants
1281            .as_ref()
1282            .and_then(|constants| {
1283                let mut visiting = HashSet::new();
1284                resolve_build_src_value(symbolic_ref, constants, &mut visiting)
1285            })
1286            .or_else(|| {
1287                resolve_nearby_sibling_build_src_value(symbolic_ref, &sibling_build_src_tiers)
1288            });
1289        let Some(resolved) = resolved else {
1290            continue;
1291        };
1292        if !resolved.contains(':') {
1293            continue;
1294        }
1295
1296        let resolved_dependency = parse_colon_string(&resolved, &raw.scope);
1297        raw.namespace = resolved_dependency.namespace;
1298        raw.name = resolved_dependency.name;
1299        raw.version = resolved_dependency.version;
1300    }
1301}
1302
1303fn find_build_src_dir(path: &Path) -> Option<PathBuf> {
1304    for ancestor in path.ancestors() {
1305        let build_src_dir = ancestor.join("buildSrc");
1306        if build_src_dir.is_dir() {
1307            return Some(build_src_dir);
1308        }
1309    }
1310    None
1311}
1312
1313fn find_nearby_sibling_build_src_tiers(path: &Path) -> Vec<Vec<PathBuf>> {
1314    let mut tiers = Vec::new();
1315
1316    for ancestor in path
1317        .ancestors()
1318        .skip(1)
1319        .capped("gradle sibling buildSrc ancestors")
1320    {
1321        let sibling_dirs = collect_sibling_build_src_dirs(ancestor, path);
1322        if !sibling_dirs.is_empty() {
1323            tiers.push(sibling_dirs);
1324        }
1325    }
1326
1327    tiers
1328}
1329
1330fn collect_sibling_build_src_dirs(ancestor: &Path, current_path: &Path) -> Vec<PathBuf> {
1331    if !ancestor.is_dir() {
1332        return Vec::new();
1333    }
1334
1335    let Ok(entries) = std::fs::read_dir(ancestor) else {
1336        return Vec::new();
1337    };
1338
1339    let mut build_src_dirs = Vec::new();
1340    for entry in entries.flatten().capped("gradle sibling directory entries") {
1341        let child_dir = entry.path();
1342        if !child_dir.is_dir() || current_path.starts_with(&child_dir) {
1343            continue;
1344        }
1345
1346        let build_src_dir = child_dir.join("buildSrc");
1347        if !build_src_dir.is_dir() || !has_gradle_settings_file(&child_dir) {
1348            continue;
1349        }
1350
1351        build_src_dirs.push(build_src_dir);
1352    }
1353
1354    build_src_dirs.sort();
1355    build_src_dirs
1356}
1357
1358fn has_gradle_settings_file(dir: &Path) -> bool {
1359    dir.join("settings.gradle").is_file() || dir.join("settings.gradle.kts").is_file()
1360}
1361
1362fn resolve_nearby_sibling_build_src_value(
1363    symbolic_ref: &str,
1364    sibling_build_src_tiers: &[Vec<PathBuf>],
1365) -> Option<String> {
1366    let tier_limit = capped_iteration_limit(
1367        sibling_build_src_tiers.len(),
1368        "gradle sibling buildSrc tiers",
1369    );
1370    for sibling_build_src_dirs in sibling_build_src_tiers.iter().take(tier_limit) {
1371        let mut resolved_value: Option<String> = None;
1372
1373        let dir_limit =
1374            capped_iteration_limit(sibling_build_src_dirs.len(), "gradle sibling buildSrc dirs");
1375        for build_src_dir in sibling_build_src_dirs.iter().take(dir_limit) {
1376            let Some(constants) = load_build_src_constants(build_src_dir) else {
1377                continue;
1378            };
1379
1380            let mut visiting = HashSet::new();
1381            let Some(candidate) = resolve_build_src_value(symbolic_ref, &constants, &mut visiting)
1382            else {
1383                continue;
1384            };
1385            if !candidate.contains(':') {
1386                continue;
1387            }
1388
1389            match &resolved_value {
1390                None => resolved_value = Some(candidate),
1391                Some(existing) if existing == &candidate => {}
1392                Some(_) => return None,
1393            }
1394        }
1395
1396        if resolved_value.is_some() {
1397            return resolved_value;
1398        }
1399    }
1400
1401    None
1402}
1403
1404fn load_build_src_constants(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1405    let cache = BUILD_SRC_CONSTANT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1406    if let Ok(guard) = cache.lock()
1407        && let Some(cached) = guard.get(build_src_dir)
1408    {
1409        return cached.clone();
1410    }
1411
1412    let parsed = parse_build_src_constants_dir(build_src_dir);
1413
1414    if let Ok(mut guard) = cache.lock() {
1415        guard.insert(build_src_dir.to_path_buf(), parsed.clone());
1416    }
1417
1418    parsed
1419}
1420
1421fn parse_build_src_constants_dir(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1422    let mut kotlin_files = Vec::new();
1423    for source_dir in [
1424        build_src_dir.join("src").join("main").join("java"),
1425        build_src_dir.join("src").join("main").join("kotlin"),
1426    ] {
1427        collect_build_src_kotlin_files(&source_dir, &mut kotlin_files);
1428    }
1429
1430    if kotlin_files.is_empty() {
1431        return None;
1432    }
1433
1434    let mut constants = HashMap::new();
1435    let limit = capped_iteration_limit(kotlin_files.len(), "gradle buildSrc kotlin files");
1436    for file in kotlin_files.into_iter().take(limit) {
1437        let Ok(content) = read_file_to_string(&file, None) else {
1438            continue;
1439        };
1440        constants.extend(parse_build_src_constants(&content));
1441    }
1442
1443    (!constants.is_empty()).then_some(constants)
1444}
1445
1446fn collect_build_src_kotlin_files(dir: &Path, files: &mut Vec<PathBuf>) {
1447    if files.len() >= MAX_ITERATION_COUNT || !dir.is_dir() {
1448        return;
1449    }
1450
1451    let Ok(entries) = std::fs::read_dir(dir) else {
1452        return;
1453    };
1454
1455    for entry in entries
1456        .flatten()
1457        .capped("gradle buildSrc directory entries")
1458    {
1459        if files.len() >= MAX_ITERATION_COUNT {
1460            break;
1461        }
1462
1463        let path = entry.path();
1464        if path.is_dir() {
1465            collect_build_src_kotlin_files(&path, files);
1466            continue;
1467        }
1468
1469        if path.extension().is_some_and(|ext| ext == "kt") {
1470            files.push(path);
1471        }
1472    }
1473}
1474
1475fn parse_build_src_constants(content: &str) -> BuildSrcConstMap {
1476    let tokens = lex(content);
1477    let mut constants = HashMap::new();
1478    let mut object_stack = Vec::new();
1479    let mut brace_stack: Vec<Option<String>> = Vec::new();
1480    let mut i = 0;
1481
1482    while i < tokens.len() && i < MAX_ITERATION_COUNT {
1483        if let Some((name, consumed)) = parse_object_declaration(&tokens[i..]) {
1484            object_stack.push(name.clone());
1485            brace_stack.push(Some(name));
1486            i += consumed;
1487            continue;
1488        }
1489
1490        if let Some((name, expr, consumed)) = parse_build_src_const_definition(&tokens[i..]) {
1491            let scope = object_stack.join(".");
1492            let full_name = if scope.is_empty() {
1493                name.clone()
1494            } else {
1495                format!("{scope}.{name}")
1496            };
1497            constants.insert(
1498                truncate_field(full_name),
1499                BuildSrcConst {
1500                    scope: truncate_field(scope),
1501                    expr,
1502                },
1503            );
1504            i += consumed;
1505            continue;
1506        }
1507
1508        match &tokens[i] {
1509            Tok::OpenBrace => brace_stack.push(None),
1510            Tok::CloseBrace => {
1511                if let Some(marker) = brace_stack.pop()
1512                    && marker.is_some()
1513                {
1514                    object_stack.pop();
1515                }
1516            }
1517            _ => {}
1518        }
1519
1520        i += 1;
1521    }
1522
1523    constants
1524}
1525
1526fn parse_object_declaration(tokens: &[Tok]) -> Option<(String, usize)> {
1527    if let [Tok::Ident(keyword), Tok::Ident(name), Tok::OpenBrace, ..] = tokens
1528        && keyword == "object"
1529    {
1530        return Some((truncate_field(name.clone()), 3));
1531    }
1532    None
1533}
1534
1535fn parse_build_src_const_definition(tokens: &[Tok]) -> Option<(String, BuildSrcExpr, usize)> {
1536    let mut cursor = 0;
1537
1538    while let Some(Tok::Ident(modifier)) = tokens.get(cursor) {
1539        if matches!(
1540            modifier.as_str(),
1541            "private" | "internal" | "public" | "protected"
1542        ) {
1543            cursor += 1;
1544            continue;
1545        }
1546        break;
1547    }
1548
1549    if !matches!(tokens.get(cursor), Some(Tok::Ident(keyword)) if keyword == "const")
1550        || !matches!(tokens.get(cursor + 1), Some(Tok::Ident(keyword)) if keyword == "val")
1551    {
1552        return None;
1553    }
1554
1555    let Tok::Ident(name) = tokens.get(cursor + 2)? else {
1556        return None;
1557    };
1558    if tokens.get(cursor + 3) != Some(&Tok::Equals) {
1559        return None;
1560    }
1561
1562    let expr = match tokens.get(cursor + 4)? {
1563        Tok::Str(value) => BuildSrcExpr::Literal(truncate_field(value.clone())),
1564        Tok::Ident(value) => BuildSrcExpr::Ref(truncate_field(value.clone())),
1565        _ => return None,
1566    };
1567
1568    Some((truncate_field(name.clone()), expr, cursor + 5))
1569}
1570
1571fn resolve_build_src_value(
1572    key: &str,
1573    constants: &BuildSrcConstMap,
1574    visiting: &mut HashSet<String>,
1575) -> Option<String> {
1576    if !visiting.insert(key.to_string()) {
1577        return None;
1578    }
1579
1580    let resolved = constants
1581        .get(key)
1582        .and_then(|constant| resolve_build_src_expr(constant, constants, visiting));
1583    visiting.remove(key);
1584    resolved
1585}
1586
1587fn resolve_build_src_expr(
1588    constant: &BuildSrcConst,
1589    constants: &BuildSrcConstMap,
1590    visiting: &mut HashSet<String>,
1591) -> Option<String> {
1592    match &constant.expr {
1593        BuildSrcExpr::Literal(value) => Some(interpolate_build_src_string(
1594            value,
1595            &constant.scope,
1596            constants,
1597            visiting,
1598        )),
1599        BuildSrcExpr::Ref(reference) => {
1600            resolve_build_src_symbol(&constant.scope, reference, constants, visiting)
1601        }
1602    }
1603}
1604
1605fn resolve_build_src_symbol(
1606    scope: &str,
1607    reference: &str,
1608    constants: &BuildSrcConstMap,
1609    visiting: &mut HashSet<String>,
1610) -> Option<String> {
1611    if reference.contains('.') {
1612        return resolve_build_src_value(reference, constants, visiting);
1613    }
1614
1615    let mut current_scope = Some(scope);
1616    while let Some(scope_name) = current_scope {
1617        if !scope_name.is_empty() {
1618            let candidate = format!("{scope_name}.{reference}");
1619            if let Some(value) = resolve_build_src_value(&candidate, constants, visiting) {
1620                return Some(value);
1621            }
1622        }
1623
1624        current_scope = scope_name.rsplit_once('.').map(|(parent, _)| parent);
1625    }
1626
1627    resolve_build_src_value(reference, constants, visiting)
1628}
1629
1630fn interpolate_build_src_string(
1631    value: &str,
1632    scope: &str,
1633    constants: &BuildSrcConstMap,
1634    visiting: &mut HashSet<String>,
1635) -> String {
1636    let chars = value.chars().collect::<Vec<_>>();
1637    let mut rendered = String::new();
1638    let mut i = 0;
1639
1640    while i < chars.len() {
1641        if chars[i] != '$' {
1642            rendered.push(chars[i]);
1643            i += 1;
1644            continue;
1645        }
1646
1647        if i + 1 >= chars.len() {
1648            rendered.push(chars[i]);
1649            break;
1650        }
1651
1652        if chars[i + 1] == '{' {
1653            let start = i;
1654            i += 2;
1655            let mut reference = String::new();
1656            while i < chars.len() && chars[i] != '}' {
1657                reference.push(chars[i]);
1658                i += 1;
1659            }
1660            if i < chars.len() && chars[i] == '}' {
1661                i += 1;
1662            }
1663
1664            if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting)
1665            {
1666                rendered.push_str(&resolved);
1667            } else {
1668                rendered.push_str(&value[start..i]);
1669            }
1670            continue;
1671        }
1672
1673        let start = i;
1674        i += 1;
1675        let mut reference = String::new();
1676        while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.') {
1677            reference.push(chars[i]);
1678            i += 1;
1679        }
1680
1681        if reference.is_empty() {
1682            rendered.push('$');
1683            continue;
1684        }
1685
1686        if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting) {
1687            rendered.push_str(&resolved);
1688        } else {
1689            rendered.push_str(&value[start..i]);
1690        }
1691    }
1692
1693    truncate_field(rendered)
1694}
1695
1696#[derive(Debug, Clone)]
1697struct GradleCatalogEntry {
1698    namespace: String,
1699    name: String,
1700    version: Option<String>,
1701}
1702
1703fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
1704    let Some(catalog_path) = find_gradle_version_catalog(path) else {
1705        return;
1706    };
1707    let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
1708        return;
1709    };
1710
1711    for dep in dependencies.iter_mut() {
1712        let alias = dep
1713            .extra_data
1714            .as_ref()
1715            .and_then(|data| data.get("catalog_alias"))
1716            .and_then(|value| value.as_str());
1717        let Some(alias) = alias else {
1718            continue;
1719        };
1720        let Some(entry) = entries.get(alias) else {
1721            continue;
1722        };
1723
1724        let mut purl = PackageUrl::new("maven", &entry.name).ok();
1725        if let Some(ref mut purl) = purl {
1726            if !entry.namespace.is_empty() {
1727                let _ = purl.with_namespace(&entry.namespace);
1728            }
1729            if let Some(version) = &entry.version {
1730                let _ = purl.with_version(version);
1731            }
1732        }
1733
1734        dep.purl = purl.map(|p| truncate_field(p.to_string()));
1735        dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1736        dep.is_pinned = Some(entry.version.is_some());
1737    }
1738}
1739
1740fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1741    for ancestor in path.ancestors() {
1742        let nested = ancestor.join("gradle").join("libs.versions.toml");
1743        if nested.is_file() {
1744            return Some(nested);
1745        }
1746
1747        let sibling = ancestor.join("libs.versions.toml");
1748        if sibling.is_file() {
1749            return Some(sibling);
1750        }
1751    }
1752
1753    None
1754}
1755
1756fn parse_gradle_version_catalog(
1757    path: &Path,
1758) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1759    let content = read_file_to_string(path, None).ok()?;
1760    let mut section = "";
1761    let mut versions = std::collections::HashMap::new();
1762    let mut libraries = std::collections::HashMap::new();
1763
1764    for line in content.lines().capped("gradle version catalog lines") {
1765        let trimmed = line.split('#').next().unwrap_or("").trim();
1766        if trimmed.is_empty() {
1767            continue;
1768        }
1769
1770        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1771            section = trimmed.trim_matches(&['[', ']'][..]);
1772            continue;
1773        }
1774
1775        let Some((key, value)) = trimmed.split_once('=') else {
1776            continue;
1777        };
1778        let key = key.trim().to_string();
1779        let value = value.trim().to_string();
1780
1781        match section {
1782            "versions" => {
1783                versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1784            }
1785            "libraries" => {
1786                libraries.insert(key, value);
1787            }
1788            _ => {}
1789        }
1790    }
1791
1792    let mut result = std::collections::HashMap::new();
1793    // `libraries` is a std HashMap; sort by alias so truncation drops a
1794    // deterministic set of entries when the cap is exceeded.
1795    let mut libraries: Vec<(String, String)> = libraries.into_iter().collect();
1796    libraries.sort_by(|a, b| a.0.cmp(&b.0));
1797    let limit = capped_iteration_limit(libraries.len(), "gradle version catalog libraries");
1798    for (alias, raw_value) in libraries.into_iter().take(limit) {
1799        let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1800            continue;
1801        };
1802        result.insert(truncate_field(alias.replace('-', ".")), entry);
1803    }
1804
1805    Some(result)
1806}
1807
1808fn parse_gradle_catalog_entry(
1809    raw_value: &str,
1810    versions: &std::collections::HashMap<String, String>,
1811) -> Option<GradleCatalogEntry> {
1812    if raw_value.starts_with('"') && raw_value.ends_with('"') {
1813        let notation = strip_quotes(raw_value);
1814        let mut parts = notation.split(':');
1815        let namespace = truncate_field(parts.next()?.to_string());
1816        let name = truncate_field(parts.next()?.to_string());
1817        let version = parts.next().map(|v| truncate_field(v.to_string()));
1818        return Some(GradleCatalogEntry {
1819            namespace,
1820            name,
1821            version,
1822        });
1823    }
1824
1825    if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1826        return None;
1827    }
1828
1829    let inner = &raw_value[1..raw_value.len() - 1];
1830    let mut fields = std::collections::HashMap::new();
1831    for pair in inner.split(',').capped("gradle catalog entry fields") {
1832        let Some((key, value)) = pair.split_once('=') else {
1833            continue;
1834        };
1835        fields.insert(
1836            truncate_field(key.trim().to_string()),
1837            truncate_field(strip_quotes(value.trim()).to_string()),
1838        );
1839    }
1840
1841    let (namespace, name) = if let Some(module) = fields.get("module") {
1842        let (group, artifact) = module.split_once(':')?;
1843        (
1844            truncate_field(group.to_string()),
1845            truncate_field(artifact.to_string()),
1846        )
1847    } else {
1848        (
1849            truncate_field(fields.get("group")?.to_string()),
1850            truncate_field(fields.get("name")?.to_string()),
1851        )
1852    };
1853
1854    let version = if let Some(version) = fields.get("version") {
1855        Some(truncate_field(version.to_string()))
1856    } else if let Some(version_ref) = fields.get("version.ref") {
1857        versions.get(version_ref).cloned().map(truncate_field)
1858    } else {
1859        None
1860    };
1861
1862    Some(GradleCatalogEntry {
1863        namespace,
1864        name,
1865        version,
1866    })
1867}
1868
1869fn strip_quotes(value: &str) -> &str {
1870    value
1871        .strip_prefix('"')
1872        .and_then(|v| v.strip_suffix('"'))
1873        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1874        .unwrap_or(value)
1875}
1876
1877fn extract_gradle_license_metadata(
1878    tokens: &[Tok],
1879) -> (
1880    Option<String>,
1881    Option<String>,
1882    Option<String>,
1883    Vec<crate::models::LicenseDetection>,
1884) {
1885    let mut i = 0;
1886    while i < tokens.len() {
1887        if let Tok::Ident(name) = &tokens[i]
1888            && name == "licenses"
1889            && i + 1 < tokens.len()
1890            && tokens[i + 1] == Tok::OpenBrace
1891            && let Some(block_end) = find_matching_brace(tokens, i + 1)
1892        {
1893            let inner = &tokens[i + 2..block_end];
1894            if let Some((license_name, license_url)) = parse_license_block(inner) {
1895                let extracted =
1896                    format_gradle_license_statement(&license_name, license_url.as_deref());
1897                let declared_candidate =
1898                    derive_gradle_license_expression(&license_name, license_url.as_deref());
1899                if let Some(declared_candidate) = declared_candidate
1900                    && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1901                {
1902                    let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1903                    let (declared, declared_spdx, detections) = build_declared_license_data(
1904                        normalized,
1905                        DeclaredLicenseMatchMetadata::single_line(matched_text),
1906                    );
1907                    return (
1908                        extracted.map(truncate_field),
1909                        declared.map(truncate_field),
1910                        declared_spdx.map(truncate_field),
1911                        detections,
1912                    );
1913                }
1914
1915                return (
1916                    extracted.map(truncate_field),
1917                    None,
1918                    None,
1919                    empty_declared_license_data().2,
1920                );
1921            }
1922            i = block_end + 1;
1923            continue;
1924        }
1925        i += 1;
1926    }
1927
1928    (None, None, None, Vec::new())
1929}
1930
1931fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1932    let mut i = 0;
1933    while i < tokens.len() {
1934        if let Tok::Ident(name) = &tokens[i]
1935            && name == "license"
1936            && i + 1 < tokens.len()
1937            && tokens[i + 1] == Tok::OpenBrace
1938            && let Some(block_end) = find_matching_brace(tokens, i + 1)
1939        {
1940            let mut license_name = None;
1941            let mut license_url = None;
1942            let block = &tokens[i + 2..block_end];
1943            let mut j = 0;
1944            while j < block.len() {
1945                if let Tok::Ident(label) = &block[j] {
1946                    let normalized = label.strip_suffix(".set").unwrap_or(label);
1947                    if (normalized == "name" || normalized == "url")
1948                        && let Some(value) = next_string_literal(block, j + 1)
1949                    {
1950                        if normalized == "name" {
1951                            license_name = Some(value);
1952                        } else {
1953                            license_url = Some(value);
1954                        }
1955                    }
1956                }
1957                j += 1;
1958            }
1959
1960            return license_name.map(|name| (name, license_url));
1961        }
1962        i += 1;
1963    }
1964    None
1965}
1966
1967fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1968    for token in tokens.iter().skip(start) {
1969        match token {
1970            Tok::Str(value) => return Some(truncate_field(value.clone())),
1971            Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1972            Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1973            _ => break,
1974        }
1975    }
1976    None
1977}
1978
1979fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1980    if tokens.get(start) != Some(&Tok::OpenBrace) {
1981        return None;
1982    }
1983    let mut depth = 1;
1984    let mut i = start + 1;
1985    while i < tokens.len() && depth > 0 {
1986        match &tokens[i] {
1987            Tok::OpenBrace => {
1988                depth += 1;
1989                if depth > MAX_RECURSION_DEPTH {
1990                    warn!(
1991                        "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1992                        MAX_RECURSION_DEPTH
1993                    );
1994                    break;
1995                }
1996            }
1997            Tok::CloseBrace => depth -= 1,
1998            _ => {}
1999        }
2000        if depth == 0 {
2001            return Some(i);
2002        }
2003        i += 1;
2004    }
2005    None
2006}
2007
2008fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
2009    let mut output = format!("- license:\n    name: {name}\n");
2010    if let Some(url) = url {
2011        output.push_str(&format!("    url: {url}\n"));
2012    }
2013    Some(truncate_field(output))
2014}
2015
2016fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
2017    let trimmed = name.trim();
2018    let candidates = [trimmed, url.unwrap_or("")];
2019
2020    for candidate in candidates {
2021        let lower = candidate.to_ascii_lowercase();
2022        if trimmed == "Apache-2.0"
2023            || lower.contains("apache-2.0")
2024            || lower.contains("apache license, version 2.0")
2025            || lower.contains("apache.org/licenses/license-2.0")
2026        {
2027            return Some(truncate_field("Apache-2.0".to_string()));
2028        }
2029        if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
2030            return Some(truncate_field("MIT".to_string()));
2031        }
2032        if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
2033            return Some(truncate_field(trimmed.to_string()));
2034        }
2035    }
2036
2037    None
2038}
2039
2040#[cfg(test)]
2041#[path = "gradle_test.rs"]
2042mod tests;