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