Skip to main content

provenant/parsers/
gradle.rs

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