Skip to main content

provenant/parsers/
gradle.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Gradle build files (Groovy and Kotlin DSL).
5//!
6//! Extracts dependencies from Gradle build scripts using a custom token-based
7//! lexer and recursive descent parser supporting both Groovy and Kotlin syntax.
8//!
9//! # Supported Formats
10//! - build.gradle (Groovy DSL)
11//! - build.gradle.kts (Kotlin DSL)
12//!
13//! # Key Features
14//! - Token-based lexer for Gradle syntax parsing (not full language parser)
15//! - Support for multiple dependency declaration styles
16//! - Dependency scope tracking (implementation, testImplementation, etc.)
17//! - Project dependency references and platform dependencies
18//! - Version interpolation and constraint parsing
19//! - Package URL (purl) generation for Maven packages
20//!
21//! # Implementation Notes
22//! - Custom 870-line lexer instead of external parser (smaller binary, easier maintenance)
23//! - Supports Groovy and Kotlin syntax variations
24//! - Graceful error handling with `warn!()` logs
25//! - Direct dependency tracking (all in build file are direct)
26
27use std::collections::{HashMap, HashSet};
28use std::path::{Path, PathBuf};
29use std::sync::{Mutex, OnceLock};
30
31use crate::parser_warn as warn;
32use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
33
34const MAX_RECURSION_DEPTH: usize = 50;
35use packageurl::PackageUrl;
36use serde_json::json;
37
38use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
39use crate::parsers::PackageParser;
40
41use super::license_normalization::{
42    DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
43    normalize_spdx_expression,
44};
45
46/// Parses Gradle build files (build.gradle, build.gradle.kts).
47///
48/// Extracts dependencies from Gradle build scripts using a custom
49/// token-based lexer and recursive descent parser. Supports both
50/// Groovy and Kotlin DSL syntax.
51///
52/// # Supported Patterns
53/// - String notation: `implementation 'group:name:version'`
54/// - Named parameters: `implementation group: 'x', name: 'y', version: 'z'`
55/// - Map format: `implementation([group: 'x', name: 'y'])`
56/// - Nested functions: `implementation(enforcedPlatform("..."))`
57/// - Project references: `implementation(project(":module"))`
58/// - String interpolation: `implementation("group:name:${version}")`
59///
60/// # Implementation
61/// Uses a custom token-based lexer (870 lines) instead of tree-sitter for:
62/// - Lighter binary size (no external parser dependencies)
63/// - Easier maintenance for DSL-specific quirks
64/// - Better error messages for malformed input
65///
66/// Typical usage is calling `GradleParser::extract_first_package()` on a
67/// `build.gradle` or `build.gradle.kts` file and then inspecting the returned
68/// dependency list.
69pub struct GradleParser;
70
71impl PackageParser for GradleParser {
72    const PACKAGE_TYPE: PackageType = PackageType::Maven;
73
74    fn is_match(path: &Path) -> bool {
75        path.file_name().is_some_and(|name| {
76            let name_str = name.to_string_lossy();
77            name_str == "build.gradle" || name_str == "build.gradle.kts"
78        })
79    }
80
81    fn extract_packages(path: &Path) -> Vec<PackageData> {
82        let content = match read_file_to_string(path, None) {
83            Ok(c) => c,
84            Err(e) => {
85                warn!("Failed to read {:?}: {}", path, e);
86                return vec![default_package_data()];
87            }
88        };
89
90        let tokens = lex(&content);
91        let dependencies = extract_dependencies_with_context(path, &content, &tokens);
92        let (
93            extracted_license_statement,
94            declared_license_expression,
95            declared_license_expression_spdx,
96            license_detections,
97        ) = extract_gradle_license_metadata(&tokens);
98
99        vec![PackageData {
100            package_type: Some(Self::PACKAGE_TYPE),
101            namespace: None,
102            name: None,
103            version: None,
104            qualifiers: None,
105            subpath: None,
106            primary_language: None,
107            description: None,
108            release_date: None,
109            parties: Vec::new(),
110            keywords: Vec::new(),
111            homepage_url: None,
112            download_url: None,
113            size: None,
114            sha1: None,
115            md5: None,
116            sha256: None,
117            sha512: None,
118            bug_tracking_url: None,
119            code_view_url: None,
120            vcs_url: None,
121            copyright: None,
122            holder: None,
123            declared_license_expression,
124            declared_license_expression_spdx,
125            license_detections,
126            other_license_expression: None,
127            other_license_expression_spdx: None,
128            other_license_detections: Vec::new(),
129            extracted_license_statement,
130            notice_text: None,
131            source_packages: Vec::new(),
132            file_references: Vec::new(),
133            extra_data: None,
134            dependencies,
135            repository_homepage_url: None,
136            repository_download_url: None,
137            api_data_url: None,
138            datasource_id: Some(DatasourceId::BuildGradle),
139            purl: None,
140            is_private: false,
141            is_virtual: false,
142        }]
143    }
144}
145
146fn default_package_data() -> PackageData {
147    PackageData {
148        package_type: Some(GradleParser::PACKAGE_TYPE),
149        datasource_id: Some(DatasourceId::BuildGradle),
150        ..Default::default()
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Lexer
156// ---------------------------------------------------------------------------
157
158#[derive(Debug, Clone, PartialEq)]
159enum Tok {
160    Ident(String),
161    Str(String),
162    MalformedStr(String),
163    OpenParen,
164    CloseParen,
165    OpenBracket,
166    CloseBracket,
167    OpenBrace,
168    CloseBrace,
169    Colon,
170    Comma,
171    Equals,
172}
173
174fn lex(input: &str) -> Vec<Tok> {
175    let chars: Vec<char> = input.chars().collect();
176    let len = chars.len();
177    let mut i = 0;
178    let mut tokens = Vec::new();
179
180    while i < len {
181        if tokens.len() >= MAX_ITERATION_COUNT {
182            warn!(
183                "Lexer exceeded MAX_ITERATION_COUNT ({}) tokens, stopping",
184                MAX_ITERATION_COUNT
185            );
186            break;
187        }
188        let c = chars[i];
189
190        if c == '/' && i + 1 < len && chars[i + 1] == '/' {
191            while i < len && chars[i] != '\n' {
192                i += 1;
193            }
194            continue;
195        }
196
197        if c == '/' && i + 1 < len && chars[i + 1] == '*' {
198            i += 2;
199            while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
200                i += 1;
201            }
202            i += 2;
203            continue;
204        }
205
206        if c.is_whitespace() {
207            i += 1;
208            continue;
209        }
210
211        if c == '\'' {
212            i += 1;
213            let start = i;
214            while i < len && chars[i] != '\'' && chars[i] != '\n' {
215                i += 1;
216            }
217            let val: String = chars[start..i].iter().collect();
218            let val = truncate_field(val);
219            if i < len && chars[i] == '\'' {
220                tokens.push(Tok::Str(val));
221                i += 1;
222            } else {
223                tokens.push(Tok::MalformedStr(val));
224            }
225            continue;
226        }
227
228        if c == '"' {
229            i += 1;
230            let start = i;
231            while i < len && chars[i] != '"' && chars[i] != '\n' {
232                if chars[i] == '\\' && i + 1 < len {
233                    i += 2;
234                } else {
235                    i += 1;
236                }
237            }
238            let val: String = chars[start..i].iter().collect();
239            let val = truncate_field(val);
240            if i < len && chars[i] == '"' {
241                tokens.push(Tok::Str(val));
242                i += 1;
243            } else {
244                tokens.push(Tok::MalformedStr(val));
245            }
246            continue;
247        }
248
249        match c {
250            '(' => {
251                tokens.push(Tok::OpenParen);
252                i += 1;
253            }
254            ')' => {
255                tokens.push(Tok::CloseParen);
256                i += 1;
257            }
258            '[' => {
259                tokens.push(Tok::OpenBracket);
260                i += 1;
261            }
262            ']' => {
263                tokens.push(Tok::CloseBracket);
264                i += 1;
265            }
266            '{' => {
267                tokens.push(Tok::OpenBrace);
268                i += 1;
269            }
270            '}' => {
271                tokens.push(Tok::CloseBrace);
272                i += 1;
273            }
274            ':' => {
275                tokens.push(Tok::Colon);
276                i += 1;
277            }
278            ',' => {
279                tokens.push(Tok::Comma);
280                i += 1;
281            }
282            '=' => {
283                tokens.push(Tok::Equals);
284                i += 1;
285            }
286            _ if is_ident_start(c) => {
287                let start = i;
288                while i < len && is_ident_char(chars[i]) {
289                    i += 1;
290                }
291                let val: String = chars[start..i].iter().collect();
292                tokens.push(Tok::Ident(truncate_field(val)));
293            }
294            _ => {
295                i += 1;
296            }
297        }
298    }
299
300    tokens
301}
302
303fn is_ident_start(c: char) -> bool {
304    c.is_ascii_alphanumeric() || c == '_' || c == '-'
305}
306
307fn is_ident_char(c: char) -> bool {
308    c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '$'
309}
310
311// ---------------------------------------------------------------------------
312// Dependency block extraction
313// ---------------------------------------------------------------------------
314
315fn find_dependency_blocks(tokens: &[Tok]) -> Vec<Vec<Tok>> {
316    let mut blocks = Vec::new();
317    let mut i = 0;
318
319    while i < tokens.len() {
320        if let Tok::Ident(ref name) = tokens[i]
321            && name == "dependencies"
322            && i + 1 < tokens.len()
323            && tokens[i + 1] == Tok::OpenBrace
324        {
325            i += 2;
326            let mut depth = 1;
327            let start = i;
328            while i < tokens.len() && depth > 0 {
329                match &tokens[i] {
330                    Tok::OpenBrace => {
331                        depth += 1;
332                        if depth > MAX_RECURSION_DEPTH {
333                            warn!(
334                                "Gradle parser: nesting depth exceeded {} in find_dependency_blocks",
335                                MAX_RECURSION_DEPTH
336                            );
337                            break;
338                        }
339                    }
340                    Tok::CloseBrace => depth -= 1,
341                    _ => {}
342                }
343                if depth > 0 {
344                    i += 1;
345                }
346            }
347            blocks.push(tokens[start..i].to_vec());
348            if i < tokens.len() {
349                i += 1;
350            }
351            continue;
352        }
353        i += 1;
354    }
355
356    blocks
357}
358
359// ---------------------------------------------------------------------------
360// Dependency extraction from blocks
361// ---------------------------------------------------------------------------
362
363#[derive(Debug, Clone, PartialEq, Eq, Hash)]
364struct RawDep {
365    namespace: String,
366    name: String,
367    version: String,
368    scope: String,
369    catalog_alias: Option<String>,
370    symbolic_ref: Option<String>,
371    project_path: Option<String>,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
375enum BuildSrcExpr {
376    Literal(String),
377    Ref(String),
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381struct BuildSrcConst {
382    scope: String,
383    expr: BuildSrcExpr,
384}
385
386type BuildSrcConstMap = HashMap<String, BuildSrcConst>;
387type BuildSrcCache = HashMap<PathBuf, Option<BuildSrcConstMap>>;
388
389static BUILD_SRC_CONSTANT_CACHE: OnceLock<Mutex<BuildSrcCache>> = OnceLock::new();
390
391fn extract_dependencies_with_context(
392    path: &Path,
393    content: &str,
394    tokens: &[Tok],
395) -> Vec<Dependency> {
396    let mut raw_dependencies = extract_raw_dependencies(tokens);
397    resolve_gradle_script_interpolations(path, content, &mut raw_dependencies);
398    resolve_gradle_buildsrc_symbolic_refs(path, &mut raw_dependencies);
399    let mut dependencies = raw_dependencies
400        .iter()
401        .filter_map(create_dependency)
402        .collect::<Vec<_>>();
403    resolve_gradle_version_catalog_aliases(path, &mut dependencies);
404    dependencies
405}
406
407#[cfg(test)]
408fn extract_dependencies(tokens: &[Tok]) -> Vec<Dependency> {
409    extract_raw_dependencies(tokens)
410        .iter()
411        .filter_map(create_dependency)
412        .collect()
413}
414
415fn extract_raw_dependencies(tokens: &[Tok]) -> Vec<RawDep> {
416    let blocks = find_dependency_blocks(tokens);
417    let mut dependencies = Vec::new();
418
419    for block in blocks {
420        for rd in parse_block(&block).into_iter().take(MAX_ITERATION_COUNT) {
421            dependencies.push(rd);
422        }
423    }
424
425    dependencies
426}
427
428fn parse_block(tokens: &[Tok]) -> Vec<RawDep> {
429    let mut deps = Vec::new();
430    let mut i = 0;
431    let mut iterations = 0;
432
433    while i < tokens.len() {
434        iterations += 1;
435        if iterations > MAX_ITERATION_COUNT {
436            warn!(
437                "parse_block exceeded MAX_ITERATION_COUNT ({}) iterations, stopping",
438                MAX_ITERATION_COUNT
439            );
440            break;
441        }
442
443        if let Some(next_index) = parse_control_flow_block(tokens, i, &mut deps) {
444            i = next_index;
445            continue;
446        }
447
448        // Skip nested blocks (closures like `{ transitive = true }`)
449        if tokens[i] == Tok::OpenBrace {
450            let mut depth = 1;
451            i += 1;
452            while i < tokens.len() && depth > 0 {
453                match &tokens[i] {
454                    Tok::OpenBrace => {
455                        depth += 1;
456                        if depth > MAX_RECURSION_DEPTH {
457                            warn!(
458                                "Gradle parser: nesting depth exceeded {} in parse_block",
459                                MAX_RECURSION_DEPTH
460                            );
461                            break;
462                        }
463                    }
464                    Tok::CloseBrace => depth -= 1,
465                    _ => {}
466                }
467                i += 1;
468            }
469            continue;
470        }
471
472        if let Tok::Str(scope_name) = &tokens[i]
473            && i + 1 < tokens.len()
474            && tokens[i + 1] == Tok::OpenParen
475            && let Some(end) = find_matching_paren(tokens, i + 1)
476        {
477            let inner = &tokens[i + 2..end];
478            parse_paren_content(scope_name, inner, &mut deps);
479            i = end + 1;
480            continue;
481        }
482
483        let scope_name = match &tokens[i] {
484            Tok::Ident(name) => name.clone(),
485            _ => {
486                i += 1;
487                continue;
488            }
489        };
490
491        if is_skip_keyword(&scope_name) {
492            i += 1;
493            continue;
494        }
495
496        let next = i + 1;
497
498        // PATTERN: scope ( ... )  — parenthesized dependency
499        if next < tokens.len() && tokens[next] == Tok::OpenParen {
500            let paren_end = find_matching_paren(tokens, next);
501            if let Some(end) = paren_end {
502                let inner = &tokens[next + 1..end];
503                parse_paren_content(&scope_name, inner, &mut deps);
504                i = end + 1;
505                continue;
506            }
507        }
508
509        // PATTERN: scope group: ..., name: ..., version: ... (named params without parens)
510        if next < tokens.len()
511            && let Tok::Ident(ref label) = tokens[next]
512            && label == "group"
513            && next + 1 < tokens.len()
514            && tokens[next + 1] == Tok::Colon
515            && let Some((rd, consumed)) = parse_named_params(&scope_name, &tokens[next..])
516        {
517            deps.push(rd);
518            i = next + consumed;
519            continue;
520        }
521
522        // PATTERN: scope 'string:notation' (string notation)
523        if next < tokens.len()
524            && matches!(
525                tokens.get(next),
526                Some(Tok::Str(_)) | Some(Tok::MalformedStr(_))
527            )
528        {
529            let (val, is_malformed) = match &tokens[next] {
530                Tok::Str(val) => (val.as_str(), false),
531                Tok::MalformedStr(val) => (val.as_str(), true),
532                _ => unreachable!(),
533            };
534
535            if !val.contains(':') {
536                i = next + 1;
537                continue;
538            }
539
540            if val.chars().next().is_some_and(|c| c.is_whitespace()) {
541                break;
542            }
543
544            // `scope 'str', { closure }` → skip (unparenthesized call with trailing closure)
545            if next + 1 < tokens.len()
546                && tokens[next + 1] == Tok::Comma
547                && next + 2 < tokens.len()
548                && tokens[next + 2] == Tok::OpenBrace
549            {
550                i = next + 1;
551                continue;
552            }
553            let is_multi = i + 2 < tokens.len()
554                && tokens[next + 1] == Tok::Comma
555                && matches!(tokens.get(next + 2), Some(Tok::Str(_)));
556            let effective_scope = if is_multi { "" } else { &scope_name };
557            let rd = parse_colon_string(val, effective_scope);
558            deps.push(rd);
559            if is_malformed {
560                break;
561            }
562            i = next + 1;
563            while i < tokens.len() && tokens[i] == Tok::Comma {
564                i += 1;
565                if i < tokens.len()
566                    && let Tok::Str(ref v2) = tokens[i]
567                    && v2.contains(':')
568                {
569                    deps.push(parse_colon_string(v2, ""));
570                    i += 1;
571                    continue;
572                }
573                break;
574            }
575            continue;
576        }
577
578        // PATTERN: scope libs.foo.bar (version catalog alias)
579        // Keep TOML-backed `libs.*` aliases for later version-catalog resolution,
580        // but ignore other unresolved dotted identifiers such as `dependencies.*`
581        // or arbitrary constants like `Deps.AndroidX.core`.
582        if next < tokens.len()
583            && let Tok::Ident(ref val) = tokens[next]
584            && val.starts_with("libs.")
585            && let Some(last_seg) = val.rsplit('.').next()
586            && !last_seg.is_empty()
587        {
588            deps.push(RawDep {
589                namespace: String::new(),
590                name: truncate_field(last_seg.to_string()),
591                version: String::new(),
592                scope: truncate_field(scope_name.clone()),
593                catalog_alias: val
594                    .strip_prefix("libs.")
595                    .map(|alias| truncate_field(alias.to_string())),
596                symbolic_ref: None,
597                project_path: None,
598            });
599            i = next + 1;
600            continue;
601        }
602
603        if next < tokens.len()
604            && let Tok::Ident(ref val) = tokens[next]
605            && val.contains('.')
606        {
607            deps.push(parse_symbolic_ref(&scope_name, val));
608            i = next + 1;
609            continue;
610        }
611
612        // PATTERN: scope project(':module') — project reference without parens
613        if next < tokens.len()
614            && let Tok::Ident(ref name) = tokens[next]
615            && name == "project"
616            && next + 1 < tokens.len()
617            && tokens[next + 1] == Tok::OpenParen
618            && let Some(end) = find_matching_paren(tokens, next + 1)
619        {
620            let inner = &tokens[next + 2..end];
621            if let Some(rd) = parse_project_ref(inner, &scope_name) {
622                deps.push(rd);
623            }
624            i = end + 1;
625            continue;
626        }
627
628        i += 1;
629    }
630
631    deps
632}
633
634fn parse_control_flow_block(tokens: &[Tok], start: usize, deps: &mut Vec<RawDep>) -> Option<usize> {
635    let Tok::Ident(keyword) = tokens.get(start)? else {
636        return None;
637    };
638
639    if keyword != "if" && keyword != "else" {
640        return None;
641    }
642
643    let mut block_start = start + 1;
644    if keyword == "if" {
645        if tokens.get(block_start) != Some(&Tok::OpenParen) {
646            return None;
647        }
648        let cond_end = find_matching_paren(tokens, block_start)?;
649        block_start = cond_end + 1;
650    } else if let Some(Tok::Ident(next)) = tokens.get(block_start)
651        && next == "if"
652    {
653        return parse_control_flow_block(tokens, block_start, deps);
654    }
655
656    if tokens.get(block_start) != Some(&Tok::OpenBrace) {
657        return None;
658    }
659
660    let block_end = find_matching_brace(tokens, block_start)?;
661    deps.extend(parse_block(&tokens[block_start + 1..block_end]));
662    Some(block_end + 1)
663}
664
665fn is_skip_keyword(name: &str) -> bool {
666    matches!(
667        name,
668        "plugins"
669            | "apply"
670            | "ext"
671            | "configurations"
672            | "repositories"
673            | "subprojects"
674            | "allprojects"
675            | "buildscript"
676            | "pluginManager"
677            | "publishing"
678            | "sourceSets"
679            | "tasks"
680            | "task"
681    )
682}
683
684fn parse_paren_content(scope: &str, tokens: &[Tok], deps: &mut Vec<RawDep>) {
685    if tokens.is_empty() {
686        return;
687    }
688
689    // Check for bracket-enclosed maps: [group: ..., name: ..., version: ...]
690    if tokens[0] == Tok::OpenBracket {
691        parse_bracket_maps(tokens, deps);
692        return;
693    }
694
695    // Check for named parameters: group: 'x' or group = "x"
696    if let Some(Tok::Ident(label)) = tokens.first()
697        && label == "group"
698        && tokens.len() > 1
699        && tokens[1] == Tok::Colon
700    {
701        if let Some((rd, _)) = parse_named_params("", tokens) {
702            deps.push(rd);
703        }
704        return;
705    }
706
707    // Check for nested function call or project reference
708    if let Some(Tok::Ident(inner_fn)) = tokens.first()
709        && tokens.len() > 1
710        && tokens[1] == Tok::OpenParen
711    {
712        if inner_fn == "project" {
713            if let Some(end) = find_matching_paren(tokens, 1) {
714                let inner = &tokens[2..end];
715                if let Some(rd) = parse_project_ref(inner, scope) {
716                    deps.push(rd);
717                }
718            }
719            return;
720        }
721
722        if let Some(end) = find_matching_paren(tokens, 1) {
723            let inner = &tokens[2..end];
724            if let Some(Tok::Str(val)) = inner.first()
725                && val.contains(':')
726            {
727                deps.push(parse_colon_string(val, inner_fn));
728                return;
729            }
730
731            if let Some(Tok::Ident(val)) = inner.first()
732                && val.contains('.')
733            {
734                deps.push(parse_symbolic_ref(inner_fn, val));
735                return;
736            }
737        }
738    }
739
740    if let Some(Tok::Ident(val)) = tokens.first()
741        && val.contains('.')
742    {
743        deps.push(parse_symbolic_ref(scope, val));
744        return;
745    }
746
747    // Simple string: ("g:n:v")
748    if let Some(Tok::Str(val)) = tokens.first()
749        && val.contains(':')
750    {
751        deps.push(parse_colon_string(val, scope));
752    }
753}
754
755fn parse_bracket_maps(tokens: &[Tok], deps: &mut Vec<RawDep>) {
756    let mut i = 0;
757    while i < tokens.len() {
758        if tokens[i] == Tok::OpenBracket
759            && let Some(end) = find_matching_bracket(tokens, i)
760        {
761            let map_tokens = &tokens[i + 1..end];
762            if let Some(rd) = parse_map_entries(map_tokens)
763                && !contains_equivalent_map_dep(deps, &rd)
764            {
765                deps.push(rd);
766            }
767            i = end + 1;
768            continue;
769        }
770        i += 1;
771    }
772}
773
774fn contains_equivalent_map_dep(existing: &[RawDep], candidate: &RawDep) -> bool {
775    existing.iter().any(|dep| {
776        dep.name == candidate.name
777            && dep.version == candidate.version
778            && dep.scope == candidate.scope
779            && (dep.namespace == candidate.namespace
780                || dep.namespace.is_empty()
781                || candidate.namespace.is_empty())
782    })
783}
784
785fn parse_map_entries(tokens: &[Tok]) -> Option<RawDep> {
786    let mut name = String::new();
787    let mut version = String::new();
788    let mut i = 0;
789
790    while i < tokens.len() {
791        if let Tok::Ident(ref label) = tokens[i]
792            && i + 2 < tokens.len()
793            && tokens[i + 1] == Tok::Colon
794            && let Tok::Str(ref val) = tokens[i + 2]
795        {
796            match label.as_str() {
797                "name" => name = truncate_field(val.clone()),
798                "version" => version = truncate_field(val.clone()),
799                _ => {}
800            }
801            i += 3;
802            if i < tokens.len() && tokens[i] == Tok::Comma {
803                i += 1;
804            }
805            continue;
806        }
807        i += 1;
808    }
809
810    if name.is_empty() {
811        return None;
812    }
813
814    Some(RawDep {
815        namespace: String::new(),
816        name,
817        version,
818        scope: String::new(),
819        catalog_alias: None,
820        symbolic_ref: None,
821        project_path: None,
822    })
823}
824
825fn parse_named_params(scope: &str, tokens: &[Tok]) -> Option<(RawDep, usize)> {
826    let mut group = String::new();
827    let mut name = String::new();
828    let mut version = String::new();
829    let mut i = 0;
830
831    while i < tokens.len() {
832        if let Tok::Ident(ref label) = tokens[i]
833            && i + 2 < tokens.len()
834            && tokens[i + 1] == Tok::Colon
835            && let Tok::Str(ref val) = tokens[i + 2]
836        {
837            match label.as_str() {
838                "group" => group = truncate_field(val.clone()),
839                "name" => name = truncate_field(val.clone()),
840                "version" => version = truncate_field(val.clone()),
841                _ => {}
842            }
843            i += 3;
844            if i < tokens.len() && tokens[i] == Tok::Comma {
845                i += 1;
846            }
847            continue;
848        }
849        break;
850    }
851
852    if name.is_empty() {
853        return None;
854    }
855
856    Some((
857        RawDep {
858            namespace: group,
859            name,
860            version,
861            scope: scope.to_string(),
862            catalog_alias: None,
863            symbolic_ref: None,
864            project_path: None,
865        },
866        i,
867    ))
868}
869
870fn parse_project_ref(tokens: &[Tok], scope: &str) -> Option<RawDep> {
871    if let Some(Tok::Str(val)) = tokens.first() {
872        let module_name = val.trim_start_matches(':');
873        let mut segments = module_name
874            .split(':')
875            .filter(|segment| !segment.is_empty())
876            .collect::<Vec<_>>();
877        let name = segments.pop().unwrap_or(module_name);
878        if name.is_empty() {
879            return None;
880        }
881        return Some(RawDep {
882            namespace: if segments.is_empty() {
883                String::new()
884            } else {
885                truncate_field(segments.join("/"))
886            },
887            name: truncate_field(name.to_string()),
888            version: String::new(),
889            scope: truncate_field(scope.to_string()),
890            catalog_alias: None,
891            symbolic_ref: None,
892            project_path: Some(truncate_field(module_name.to_string())),
893        });
894    }
895    None
896}
897
898fn parse_symbolic_ref(scope: &str, value: &str) -> RawDep {
899    RawDep {
900        namespace: String::new(),
901        name: String::new(),
902        version: String::new(),
903        scope: truncate_field(scope.to_string()),
904        catalog_alias: None,
905        symbolic_ref: Some(truncate_field(value.to_string())),
906        project_path: None,
907    }
908}
909
910fn parse_colon_string(val: &str, scope: &str) -> RawDep {
911    let parts: Vec<&str> = val.split(':').collect();
912    let (namespace, name, version) = match parts.len() {
913        n if n >= 4 => (
914            truncate_field(parts[0].to_string()),
915            truncate_field(parts[1].to_string()),
916            truncate_field(parts[2].to_string()),
917        ),
918        3 => (
919            truncate_field(parts[0].to_string()),
920            truncate_field(parts[1].to_string()),
921            truncate_field(parts[2].to_string()),
922        ),
923        2 => (
924            truncate_field(parts[0].to_string()),
925            truncate_field(parts[1].to_string()),
926            String::new(),
927        ),
928        _ => (
929            String::new(),
930            truncate_field(val.to_string()),
931            String::new(),
932        ),
933    };
934
935    RawDep {
936        namespace,
937        name,
938        version,
939        scope: truncate_field(scope.to_string()),
940        catalog_alias: None,
941        symbolic_ref: None,
942        project_path: None,
943    }
944}
945
946fn find_matching_paren(tokens: &[Tok], start: usize) -> Option<usize> {
947    if tokens.get(start) != Some(&Tok::OpenParen) {
948        return None;
949    }
950    let mut depth = 1;
951    let mut i = start + 1;
952    while i < tokens.len() && depth > 0 {
953        match &tokens[i] {
954            Tok::OpenParen => {
955                depth += 1;
956                if depth > MAX_RECURSION_DEPTH {
957                    warn!(
958                        "Gradle parser: nesting depth exceeded {} in find_matching_paren",
959                        MAX_RECURSION_DEPTH
960                    );
961                    break;
962                }
963            }
964            Tok::CloseParen => depth -= 1,
965            _ => {}
966        }
967        if depth == 0 {
968            return Some(i);
969        }
970        i += 1;
971    }
972    None
973}
974
975fn find_matching_bracket(tokens: &[Tok], start: usize) -> Option<usize> {
976    if tokens.get(start) != Some(&Tok::OpenBracket) {
977        return None;
978    }
979    let mut depth = 1;
980    let mut i = start + 1;
981    while i < tokens.len() && depth > 0 {
982        match &tokens[i] {
983            Tok::OpenBracket => {
984                depth += 1;
985                if depth > MAX_RECURSION_DEPTH {
986                    warn!(
987                        "Gradle parser: nesting depth exceeded {} in find_matching_bracket",
988                        MAX_RECURSION_DEPTH
989                    );
990                    break;
991                }
992            }
993            Tok::CloseBracket => depth -= 1,
994            _ => {}
995        }
996        if depth == 0 {
997            return Some(i);
998        }
999        i += 1;
1000    }
1001    None
1002}
1003
1004// ---------------------------------------------------------------------------
1005// Dependency construction
1006// ---------------------------------------------------------------------------
1007
1008fn create_dependency(raw: &RawDep) -> Option<Dependency> {
1009    let namespace = raw.namespace.as_str();
1010    let name = raw.name.as_str();
1011    let version = raw.version.as_str();
1012    let scope = raw.scope.as_str();
1013    if name.is_empty() {
1014        return None;
1015    }
1016
1017    let mut purl = PackageUrl::new("maven", name).ok()?;
1018
1019    if !namespace.is_empty() {
1020        purl.with_namespace(namespace).ok()?;
1021    }
1022
1023    if !version.is_empty() {
1024        purl.with_version(version).ok()?;
1025    }
1026
1027    let (is_runtime, is_optional) = classify_scope(scope);
1028    let is_pinned = !version.is_empty();
1029
1030    let purl_string = truncate_field(purl.to_string().replace("$", "%24").replace('\'', "%27"));
1031    let mut extra_data = std::collections::HashMap::new();
1032    if let Some(alias) = &raw.catalog_alias {
1033        extra_data.insert(
1034            "catalog_alias".to_string(),
1035            json!(truncate_field(alias.clone())),
1036        );
1037    }
1038    if let Some(project_path) = &raw.project_path {
1039        extra_data.insert(
1040            "project_path".to_string(),
1041            json!(truncate_field(project_path.clone())),
1042        );
1043    }
1044    if let Some(symbolic_ref) = &raw.symbolic_ref {
1045        extra_data.insert(
1046            "symbolic_ref".to_string(),
1047            json!(truncate_field(symbolic_ref.clone())),
1048        );
1049    }
1050
1051    Some(Dependency {
1052        purl: Some(purl_string),
1053        extracted_requirement: Some(truncate_field(version.to_string())),
1054        scope: Some(truncate_field(scope.to_string())),
1055        is_runtime: Some(is_runtime),
1056        is_optional: Some(is_optional),
1057        is_pinned: Some(is_pinned),
1058        is_direct: Some(true),
1059        resolved_package: None,
1060        extra_data: (!extra_data.is_empty()).then_some(extra_data),
1061    })
1062}
1063
1064fn classify_scope(scope: &str) -> (bool, bool) {
1065    let scope_lower = scope.to_lowercase();
1066
1067    if scope_lower.contains("test") {
1068        return (false, true);
1069    }
1070
1071    if matches!(
1072        scope_lower.as_str(),
1073        "compileonly" | "compileonlyapi" | "annotationprocessor" | "kapt" | "ksp"
1074    ) {
1075        return (false, false);
1076    }
1077
1078    (true, false)
1079}
1080
1081fn resolve_gradle_script_interpolations(
1082    path: &Path,
1083    content: &str,
1084    raw_dependencies: &mut [RawDep],
1085) {
1086    let properties = load_gradle_script_properties(path, content);
1087    if properties.is_empty() {
1088        return;
1089    }
1090
1091    for raw in raw_dependencies.iter_mut() {
1092        raw.namespace = interpolate_gradle_string(&raw.namespace, &properties);
1093        raw.name = interpolate_gradle_string(&raw.name, &properties);
1094        raw.version = interpolate_gradle_string(&raw.version, &properties);
1095    }
1096}
1097
1098fn load_gradle_script_properties(path: &Path, content: &str) -> HashMap<String, String> {
1099    let mut properties = load_gradle_properties(path);
1100
1101    let literal_assignment_patterns = [
1102        regex::Regex::new(
1103            r#"(?m)^\s*(?:const\s+)?(?:val|var|def)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=\s*['\"]([^'\"]+)['\"]"#,
1104        )
1105        .expect("valid regex"),
1106        regex::Regex::new(r#"(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['\"]([^'\"]+)['\"]"#)
1107            .expect("valid regex"),
1108    ];
1109
1110    for pattern in literal_assignment_patterns {
1111        for captures in pattern.captures_iter(content).take(MAX_ITERATION_COUNT) {
1112            let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1113                continue;
1114            };
1115            let Some(raw_value) = captures.get(2).map(|value| value.as_str()) else {
1116                continue;
1117            };
1118            let resolved = interpolate_gradle_string(raw_value, &properties);
1119            properties.insert(name.to_string(), resolved);
1120        }
1121    }
1122
1123    let delegated_project_property_pattern = regex::Regex::new(
1124        r#"(?m)^\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?\s+by\s+project\b"#,
1125    )
1126    .expect("valid regex");
1127
1128    for captures in delegated_project_property_pattern
1129        .captures_iter(content)
1130        .take(MAX_ITERATION_COUNT)
1131    {
1132        let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1133            continue;
1134        };
1135        if let Some(value) = properties.get(name).cloned() {
1136            properties.insert(name.to_string(), value);
1137        }
1138    }
1139
1140    properties
1141}
1142
1143fn load_gradle_properties(path: &Path) -> HashMap<String, String> {
1144    for ancestor in path.ancestors() {
1145        let gradle_properties = ancestor.join("gradle.properties");
1146        if !gradle_properties.is_file() {
1147            continue;
1148        }
1149
1150        let Ok(content) = read_file_to_string(&gradle_properties, None) else {
1151            continue;
1152        };
1153
1154        let mut properties = HashMap::new();
1155        for line in content.lines().take(MAX_ITERATION_COUNT) {
1156            let trimmed = line.split('#').next().unwrap_or("").trim();
1157            if trimmed.is_empty() {
1158                continue;
1159            }
1160
1161            let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':'))
1162            else {
1163                continue;
1164            };
1165
1166            let key = key.trim();
1167            let value = value.trim();
1168            if key.is_empty() || value.is_empty() {
1169                continue;
1170            }
1171            properties.insert(key.to_string(), value.to_string());
1172        }
1173        return properties;
1174    }
1175
1176    HashMap::new()
1177}
1178
1179fn interpolate_gradle_string(value: &str, properties: &HashMap<String, String>) -> String {
1180    if !value.contains('$') {
1181        return truncate_field(value.to_string());
1182    }
1183
1184    let chars = value.chars().collect::<Vec<_>>();
1185    let mut rendered = String::new();
1186    let mut i = 0;
1187
1188    while i < chars.len() {
1189        if chars[i] != '$' {
1190            rendered.push(chars[i]);
1191            i += 1;
1192            continue;
1193        }
1194
1195        if i + 1 >= chars.len() {
1196            rendered.push(chars[i]);
1197            break;
1198        }
1199
1200        if chars[i + 1] == '{' {
1201            let start = i;
1202            i += 2;
1203            let mut reference = String::new();
1204            while i < chars.len() && chars[i] != '}' {
1205                reference.push(chars[i]);
1206                i += 1;
1207            }
1208            if i < chars.len() && chars[i] == '}' {
1209                i += 1;
1210            }
1211
1212            if let Some(resolved) = properties.get(reference.trim()) {
1213                rendered.push_str(resolved);
1214            } else {
1215                rendered.push_str(&value[start..i]);
1216            }
1217            continue;
1218        }
1219
1220        let start = i;
1221        i += 1;
1222        let mut reference = String::new();
1223        while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_') {
1224            reference.push(chars[i]);
1225            i += 1;
1226        }
1227
1228        if reference.is_empty() {
1229            rendered.push('$');
1230            continue;
1231        }
1232
1233        if let Some(resolved) = properties.get(reference.as_str()) {
1234            rendered.push_str(resolved);
1235        } else {
1236            rendered.push_str(&value[start..i]);
1237        }
1238    }
1239
1240    truncate_field(rendered)
1241}
1242
1243fn resolve_gradle_buildsrc_symbolic_refs(path: &Path, raw_dependencies: &mut [RawDep]) {
1244    let ancestor_build_src_dir = find_build_src_dir(path);
1245    let ancestor_constants = ancestor_build_src_dir
1246        .as_deref()
1247        .and_then(load_build_src_constants);
1248    let sibling_build_src_tiers = if ancestor_build_src_dir.is_none() {
1249        find_nearby_sibling_build_src_tiers(path)
1250    } else {
1251        Vec::new()
1252    };
1253
1254    for raw in raw_dependencies.iter_mut() {
1255        let Some(symbolic_ref) = raw.symbolic_ref.as_deref() else {
1256            continue;
1257        };
1258
1259        let resolved = ancestor_constants
1260            .as_ref()
1261            .and_then(|constants| {
1262                let mut visiting = HashSet::new();
1263                resolve_build_src_value(symbolic_ref, constants, &mut visiting)
1264            })
1265            .or_else(|| {
1266                resolve_nearby_sibling_build_src_value(symbolic_ref, &sibling_build_src_tiers)
1267            });
1268        let Some(resolved) = resolved else {
1269            continue;
1270        };
1271        if !resolved.contains(':') {
1272            continue;
1273        }
1274
1275        let resolved_dependency = parse_colon_string(&resolved, &raw.scope);
1276        raw.namespace = resolved_dependency.namespace;
1277        raw.name = resolved_dependency.name;
1278        raw.version = resolved_dependency.version;
1279    }
1280}
1281
1282fn find_build_src_dir(path: &Path) -> Option<PathBuf> {
1283    for ancestor in path.ancestors() {
1284        let build_src_dir = ancestor.join("buildSrc");
1285        if build_src_dir.is_dir() {
1286            return Some(build_src_dir);
1287        }
1288    }
1289    None
1290}
1291
1292fn find_nearby_sibling_build_src_tiers(path: &Path) -> Vec<Vec<PathBuf>> {
1293    let mut tiers = Vec::new();
1294
1295    for ancestor in path.ancestors().skip(1).take(MAX_ITERATION_COUNT) {
1296        let sibling_dirs = collect_sibling_build_src_dirs(ancestor, path);
1297        if !sibling_dirs.is_empty() {
1298            tiers.push(sibling_dirs);
1299        }
1300    }
1301
1302    tiers
1303}
1304
1305fn collect_sibling_build_src_dirs(ancestor: &Path, current_path: &Path) -> Vec<PathBuf> {
1306    if !ancestor.is_dir() {
1307        return Vec::new();
1308    }
1309
1310    let Ok(entries) = std::fs::read_dir(ancestor) else {
1311        return Vec::new();
1312    };
1313
1314    let mut build_src_dirs = Vec::new();
1315    for entry in entries.flatten().take(MAX_ITERATION_COUNT) {
1316        let child_dir = entry.path();
1317        if !child_dir.is_dir() || current_path.starts_with(&child_dir) {
1318            continue;
1319        }
1320
1321        let build_src_dir = child_dir.join("buildSrc");
1322        if !build_src_dir.is_dir() || !has_gradle_settings_file(&child_dir) {
1323            continue;
1324        }
1325
1326        build_src_dirs.push(build_src_dir);
1327    }
1328
1329    build_src_dirs.sort();
1330    build_src_dirs
1331}
1332
1333fn has_gradle_settings_file(dir: &Path) -> bool {
1334    dir.join("settings.gradle").is_file() || dir.join("settings.gradle.kts").is_file()
1335}
1336
1337fn resolve_nearby_sibling_build_src_value(
1338    symbolic_ref: &str,
1339    sibling_build_src_tiers: &[Vec<PathBuf>],
1340) -> Option<String> {
1341    for sibling_build_src_dirs in sibling_build_src_tiers.iter().take(MAX_ITERATION_COUNT) {
1342        let mut resolved_value: Option<String> = None;
1343
1344        for build_src_dir in sibling_build_src_dirs.iter().take(MAX_ITERATION_COUNT) {
1345            let Some(constants) = load_build_src_constants(build_src_dir) else {
1346                continue;
1347            };
1348
1349            let mut visiting = HashSet::new();
1350            let Some(candidate) = resolve_build_src_value(symbolic_ref, &constants, &mut visiting)
1351            else {
1352                continue;
1353            };
1354            if !candidate.contains(':') {
1355                continue;
1356            }
1357
1358            match &resolved_value {
1359                None => resolved_value = Some(candidate),
1360                Some(existing) if existing == &candidate => {}
1361                Some(_) => return None,
1362            }
1363        }
1364
1365        if resolved_value.is_some() {
1366            return resolved_value;
1367        }
1368    }
1369
1370    None
1371}
1372
1373fn load_build_src_constants(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1374    let cache = BUILD_SRC_CONSTANT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1375    if let Ok(guard) = cache.lock()
1376        && let Some(cached) = guard.get(build_src_dir)
1377    {
1378        return cached.clone();
1379    }
1380
1381    let parsed = parse_build_src_constants_dir(build_src_dir);
1382
1383    if let Ok(mut guard) = cache.lock() {
1384        guard.insert(build_src_dir.to_path_buf(), parsed.clone());
1385    }
1386
1387    parsed
1388}
1389
1390fn parse_build_src_constants_dir(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1391    let mut kotlin_files = Vec::new();
1392    for source_dir in [
1393        build_src_dir.join("src").join("main").join("java"),
1394        build_src_dir.join("src").join("main").join("kotlin"),
1395    ] {
1396        collect_build_src_kotlin_files(&source_dir, &mut kotlin_files);
1397    }
1398
1399    if kotlin_files.is_empty() {
1400        return None;
1401    }
1402
1403    let mut constants = HashMap::new();
1404    for file in kotlin_files.into_iter().take(MAX_ITERATION_COUNT) {
1405        let Ok(content) = read_file_to_string(&file, None) else {
1406            continue;
1407        };
1408        constants.extend(parse_build_src_constants(&content));
1409    }
1410
1411    (!constants.is_empty()).then_some(constants)
1412}
1413
1414fn collect_build_src_kotlin_files(dir: &Path, files: &mut Vec<PathBuf>) {
1415    if files.len() >= MAX_ITERATION_COUNT || !dir.is_dir() {
1416        return;
1417    }
1418
1419    let Ok(entries) = std::fs::read_dir(dir) else {
1420        return;
1421    };
1422
1423    for entry in entries.flatten().take(MAX_ITERATION_COUNT) {
1424        if files.len() >= MAX_ITERATION_COUNT {
1425            break;
1426        }
1427
1428        let path = entry.path();
1429        if path.is_dir() {
1430            collect_build_src_kotlin_files(&path, files);
1431            continue;
1432        }
1433
1434        if path.extension().is_some_and(|ext| ext == "kt") {
1435            files.push(path);
1436        }
1437    }
1438}
1439
1440fn parse_build_src_constants(content: &str) -> BuildSrcConstMap {
1441    let tokens = lex(content);
1442    let mut constants = HashMap::new();
1443    let mut object_stack = Vec::new();
1444    let mut brace_stack: Vec<Option<String>> = Vec::new();
1445    let mut i = 0;
1446
1447    while i < tokens.len() && i < MAX_ITERATION_COUNT {
1448        if let Some((name, consumed)) = parse_object_declaration(&tokens[i..]) {
1449            object_stack.push(name.clone());
1450            brace_stack.push(Some(name));
1451            i += consumed;
1452            continue;
1453        }
1454
1455        if let Some((name, expr, consumed)) = parse_build_src_const_definition(&tokens[i..]) {
1456            let scope = object_stack.join(".");
1457            let full_name = if scope.is_empty() {
1458                name.clone()
1459            } else {
1460                format!("{scope}.{name}")
1461            };
1462            constants.insert(
1463                truncate_field(full_name),
1464                BuildSrcConst {
1465                    scope: truncate_field(scope),
1466                    expr,
1467                },
1468            );
1469            i += consumed;
1470            continue;
1471        }
1472
1473        match &tokens[i] {
1474            Tok::OpenBrace => brace_stack.push(None),
1475            Tok::CloseBrace => {
1476                if let Some(marker) = brace_stack.pop()
1477                    && marker.is_some()
1478                {
1479                    object_stack.pop();
1480                }
1481            }
1482            _ => {}
1483        }
1484
1485        i += 1;
1486    }
1487
1488    constants
1489}
1490
1491fn parse_object_declaration(tokens: &[Tok]) -> Option<(String, usize)> {
1492    if let [Tok::Ident(keyword), Tok::Ident(name), Tok::OpenBrace, ..] = tokens
1493        && keyword == "object"
1494    {
1495        return Some((truncate_field(name.clone()), 3));
1496    }
1497    None
1498}
1499
1500fn parse_build_src_const_definition(tokens: &[Tok]) -> Option<(String, BuildSrcExpr, usize)> {
1501    let mut cursor = 0;
1502
1503    while let Some(Tok::Ident(modifier)) = tokens.get(cursor) {
1504        if matches!(
1505            modifier.as_str(),
1506            "private" | "internal" | "public" | "protected"
1507        ) {
1508            cursor += 1;
1509            continue;
1510        }
1511        break;
1512    }
1513
1514    if !matches!(tokens.get(cursor), Some(Tok::Ident(keyword)) if keyword == "const")
1515        || !matches!(tokens.get(cursor + 1), Some(Tok::Ident(keyword)) if keyword == "val")
1516    {
1517        return None;
1518    }
1519
1520    let Tok::Ident(name) = tokens.get(cursor + 2)? else {
1521        return None;
1522    };
1523    if tokens.get(cursor + 3) != Some(&Tok::Equals) {
1524        return None;
1525    }
1526
1527    let expr = match tokens.get(cursor + 4)? {
1528        Tok::Str(value) => BuildSrcExpr::Literal(truncate_field(value.clone())),
1529        Tok::Ident(value) => BuildSrcExpr::Ref(truncate_field(value.clone())),
1530        _ => return None,
1531    };
1532
1533    Some((truncate_field(name.clone()), expr, cursor + 5))
1534}
1535
1536fn resolve_build_src_value(
1537    key: &str,
1538    constants: &BuildSrcConstMap,
1539    visiting: &mut HashSet<String>,
1540) -> Option<String> {
1541    if !visiting.insert(key.to_string()) {
1542        return None;
1543    }
1544
1545    let resolved = constants
1546        .get(key)
1547        .and_then(|constant| resolve_build_src_expr(constant, constants, visiting));
1548    visiting.remove(key);
1549    resolved
1550}
1551
1552fn resolve_build_src_expr(
1553    constant: &BuildSrcConst,
1554    constants: &BuildSrcConstMap,
1555    visiting: &mut HashSet<String>,
1556) -> Option<String> {
1557    match &constant.expr {
1558        BuildSrcExpr::Literal(value) => Some(interpolate_build_src_string(
1559            value,
1560            &constant.scope,
1561            constants,
1562            visiting,
1563        )),
1564        BuildSrcExpr::Ref(reference) => {
1565            resolve_build_src_symbol(&constant.scope, reference, constants, visiting)
1566        }
1567    }
1568}
1569
1570fn resolve_build_src_symbol(
1571    scope: &str,
1572    reference: &str,
1573    constants: &BuildSrcConstMap,
1574    visiting: &mut HashSet<String>,
1575) -> Option<String> {
1576    if reference.contains('.') {
1577        return resolve_build_src_value(reference, constants, visiting);
1578    }
1579
1580    let mut current_scope = Some(scope);
1581    while let Some(scope_name) = current_scope {
1582        if !scope_name.is_empty() {
1583            let candidate = format!("{scope_name}.{reference}");
1584            if let Some(value) = resolve_build_src_value(&candidate, constants, visiting) {
1585                return Some(value);
1586            }
1587        }
1588
1589        current_scope = scope_name.rsplit_once('.').map(|(parent, _)| parent);
1590    }
1591
1592    resolve_build_src_value(reference, constants, visiting)
1593}
1594
1595fn interpolate_build_src_string(
1596    value: &str,
1597    scope: &str,
1598    constants: &BuildSrcConstMap,
1599    visiting: &mut HashSet<String>,
1600) -> String {
1601    let chars = value.chars().collect::<Vec<_>>();
1602    let mut rendered = String::new();
1603    let mut i = 0;
1604
1605    while i < chars.len() {
1606        if chars[i] != '$' {
1607            rendered.push(chars[i]);
1608            i += 1;
1609            continue;
1610        }
1611
1612        if i + 1 >= chars.len() {
1613            rendered.push(chars[i]);
1614            break;
1615        }
1616
1617        if chars[i + 1] == '{' {
1618            let start = i;
1619            i += 2;
1620            let mut reference = String::new();
1621            while i < chars.len() && chars[i] != '}' {
1622                reference.push(chars[i]);
1623                i += 1;
1624            }
1625            if i < chars.len() && chars[i] == '}' {
1626                i += 1;
1627            }
1628
1629            if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting)
1630            {
1631                rendered.push_str(&resolved);
1632            } else {
1633                rendered.push_str(&value[start..i]);
1634            }
1635            continue;
1636        }
1637
1638        let start = i;
1639        i += 1;
1640        let mut reference = String::new();
1641        while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.') {
1642            reference.push(chars[i]);
1643            i += 1;
1644        }
1645
1646        if reference.is_empty() {
1647            rendered.push('$');
1648            continue;
1649        }
1650
1651        if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting) {
1652            rendered.push_str(&resolved);
1653        } else {
1654            rendered.push_str(&value[start..i]);
1655        }
1656    }
1657
1658    truncate_field(rendered)
1659}
1660
1661#[derive(Debug, Clone)]
1662struct GradleCatalogEntry {
1663    namespace: String,
1664    name: String,
1665    version: Option<String>,
1666}
1667
1668fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
1669    let Some(catalog_path) = find_gradle_version_catalog(path) else {
1670        return;
1671    };
1672    let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
1673        return;
1674    };
1675
1676    for dep in dependencies.iter_mut() {
1677        let alias = dep
1678            .extra_data
1679            .as_ref()
1680            .and_then(|data| data.get("catalog_alias"))
1681            .and_then(|value| value.as_str());
1682        let Some(alias) = alias else {
1683            continue;
1684        };
1685        let Some(entry) = entries.get(alias) else {
1686            continue;
1687        };
1688
1689        let mut purl = PackageUrl::new("maven", &entry.name).ok();
1690        if let Some(ref mut purl) = purl {
1691            if !entry.namespace.is_empty() {
1692                let _ = purl.with_namespace(&entry.namespace);
1693            }
1694            if let Some(version) = &entry.version {
1695                let _ = purl.with_version(version);
1696            }
1697        }
1698
1699        dep.purl = purl.map(|p| truncate_field(p.to_string()));
1700        dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1701        dep.is_pinned = Some(entry.version.is_some());
1702    }
1703}
1704
1705fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1706    for ancestor in path.ancestors() {
1707        let nested = ancestor.join("gradle").join("libs.versions.toml");
1708        if nested.is_file() {
1709            return Some(nested);
1710        }
1711
1712        let sibling = ancestor.join("libs.versions.toml");
1713        if sibling.is_file() {
1714            return Some(sibling);
1715        }
1716    }
1717
1718    None
1719}
1720
1721fn parse_gradle_version_catalog(
1722    path: &Path,
1723) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1724    let content = read_file_to_string(path, None).ok()?;
1725    let mut section = "";
1726    let mut versions = std::collections::HashMap::new();
1727    let mut libraries = std::collections::HashMap::new();
1728
1729    for line in content.lines().take(MAX_ITERATION_COUNT) {
1730        let trimmed = line.split('#').next().unwrap_or("").trim();
1731        if trimmed.is_empty() {
1732            continue;
1733        }
1734
1735        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1736            section = trimmed.trim_matches(&['[', ']'][..]);
1737            continue;
1738        }
1739
1740        let Some((key, value)) = trimmed.split_once('=') else {
1741            continue;
1742        };
1743        let key = key.trim().to_string();
1744        let value = value.trim().to_string();
1745
1746        match section {
1747            "versions" => {
1748                versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1749            }
1750            "libraries" => {
1751                libraries.insert(key, value);
1752            }
1753            _ => {}
1754        }
1755    }
1756
1757    let mut result = std::collections::HashMap::new();
1758    for (alias, raw_value) in libraries.into_iter().take(MAX_ITERATION_COUNT) {
1759        let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1760            continue;
1761        };
1762        result.insert(truncate_field(alias.replace('-', ".")), entry);
1763    }
1764
1765    Some(result)
1766}
1767
1768fn parse_gradle_catalog_entry(
1769    raw_value: &str,
1770    versions: &std::collections::HashMap<String, String>,
1771) -> Option<GradleCatalogEntry> {
1772    if raw_value.starts_with('"') && raw_value.ends_with('"') {
1773        let notation = strip_quotes(raw_value);
1774        let mut parts = notation.split(':');
1775        let namespace = truncate_field(parts.next()?.to_string());
1776        let name = truncate_field(parts.next()?.to_string());
1777        let version = parts.next().map(|v| truncate_field(v.to_string()));
1778        return Some(GradleCatalogEntry {
1779            namespace,
1780            name,
1781            version,
1782        });
1783    }
1784
1785    if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1786        return None;
1787    }
1788
1789    let inner = &raw_value[1..raw_value.len() - 1];
1790    let mut fields = std::collections::HashMap::new();
1791    for pair in inner.split(',').take(MAX_ITERATION_COUNT) {
1792        let Some((key, value)) = pair.split_once('=') else {
1793            continue;
1794        };
1795        fields.insert(
1796            truncate_field(key.trim().to_string()),
1797            truncate_field(strip_quotes(value.trim()).to_string()),
1798        );
1799    }
1800
1801    let (namespace, name) = if let Some(module) = fields.get("module") {
1802        let (group, artifact) = module.split_once(':')?;
1803        (
1804            truncate_field(group.to_string()),
1805            truncate_field(artifact.to_string()),
1806        )
1807    } else {
1808        (
1809            truncate_field(fields.get("group")?.to_string()),
1810            truncate_field(fields.get("name")?.to_string()),
1811        )
1812    };
1813
1814    let version = if let Some(version) = fields.get("version") {
1815        Some(truncate_field(version.to_string()))
1816    } else if let Some(version_ref) = fields.get("version.ref") {
1817        versions.get(version_ref).cloned().map(truncate_field)
1818    } else {
1819        None
1820    };
1821
1822    Some(GradleCatalogEntry {
1823        namespace,
1824        name,
1825        version,
1826    })
1827}
1828
1829fn strip_quotes(value: &str) -> &str {
1830    value
1831        .strip_prefix('"')
1832        .and_then(|v| v.strip_suffix('"'))
1833        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1834        .unwrap_or(value)
1835}
1836
1837fn extract_gradle_license_metadata(
1838    tokens: &[Tok],
1839) -> (
1840    Option<String>,
1841    Option<String>,
1842    Option<String>,
1843    Vec<crate::models::LicenseDetection>,
1844) {
1845    let mut i = 0;
1846    while i < tokens.len() {
1847        if let Tok::Ident(name) = &tokens[i]
1848            && name == "licenses"
1849            && i + 1 < tokens.len()
1850            && tokens[i + 1] == Tok::OpenBrace
1851            && let Some(block_end) = find_matching_brace(tokens, i + 1)
1852        {
1853            let inner = &tokens[i + 2..block_end];
1854            if let Some((license_name, license_url)) = parse_license_block(inner) {
1855                let extracted =
1856                    format_gradle_license_statement(&license_name, license_url.as_deref());
1857                let declared_candidate =
1858                    derive_gradle_license_expression(&license_name, license_url.as_deref());
1859                if let Some(declared_candidate) = declared_candidate
1860                    && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1861                {
1862                    let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1863                    let (declared, declared_spdx, detections) = build_declared_license_data(
1864                        normalized,
1865                        DeclaredLicenseMatchMetadata::single_line(matched_text),
1866                    );
1867                    return (
1868                        extracted.map(truncate_field),
1869                        declared.map(truncate_field),
1870                        declared_spdx.map(truncate_field),
1871                        detections,
1872                    );
1873                }
1874
1875                return (
1876                    extracted.map(truncate_field),
1877                    None,
1878                    None,
1879                    empty_declared_license_data().2,
1880                );
1881            }
1882            i = block_end + 1;
1883            continue;
1884        }
1885        i += 1;
1886    }
1887
1888    (None, None, None, Vec::new())
1889}
1890
1891fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1892    let mut i = 0;
1893    while i < tokens.len() {
1894        if let Tok::Ident(name) = &tokens[i]
1895            && name == "license"
1896            && i + 1 < tokens.len()
1897            && tokens[i + 1] == Tok::OpenBrace
1898            && let Some(block_end) = find_matching_brace(tokens, i + 1)
1899        {
1900            let mut license_name = None;
1901            let mut license_url = None;
1902            let block = &tokens[i + 2..block_end];
1903            let mut j = 0;
1904            while j < block.len() {
1905                if let Tok::Ident(label) = &block[j] {
1906                    let normalized = label.strip_suffix(".set").unwrap_or(label);
1907                    if (normalized == "name" || normalized == "url")
1908                        && let Some(value) = next_string_literal(block, j + 1)
1909                    {
1910                        if normalized == "name" {
1911                            license_name = Some(value);
1912                        } else {
1913                            license_url = Some(value);
1914                        }
1915                    }
1916                }
1917                j += 1;
1918            }
1919
1920            return license_name.map(|name| (name, license_url));
1921        }
1922        i += 1;
1923    }
1924    None
1925}
1926
1927fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1928    for token in tokens.iter().skip(start) {
1929        match token {
1930            Tok::Str(value) => return Some(truncate_field(value.clone())),
1931            Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1932            Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1933            _ => break,
1934        }
1935    }
1936    None
1937}
1938
1939fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1940    if tokens.get(start) != Some(&Tok::OpenBrace) {
1941        return None;
1942    }
1943    let mut depth = 1;
1944    let mut i = start + 1;
1945    while i < tokens.len() && depth > 0 {
1946        match &tokens[i] {
1947            Tok::OpenBrace => {
1948                depth += 1;
1949                if depth > MAX_RECURSION_DEPTH {
1950                    warn!(
1951                        "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1952                        MAX_RECURSION_DEPTH
1953                    );
1954                    break;
1955                }
1956            }
1957            Tok::CloseBrace => depth -= 1,
1958            _ => {}
1959        }
1960        if depth == 0 {
1961            return Some(i);
1962        }
1963        i += 1;
1964    }
1965    None
1966}
1967
1968fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
1969    let mut output = format!("- license:\n    name: {name}\n");
1970    if let Some(url) = url {
1971        output.push_str(&format!("    url: {url}\n"));
1972    }
1973    Some(truncate_field(output))
1974}
1975
1976fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
1977    let trimmed = name.trim();
1978    let candidates = [trimmed, url.unwrap_or("")];
1979
1980    for candidate in candidates {
1981        let lower = candidate.to_ascii_lowercase();
1982        if trimmed == "Apache-2.0"
1983            || lower.contains("apache-2.0")
1984            || lower.contains("apache license, version 2.0")
1985            || lower.contains("apache.org/licenses/license-2.0")
1986        {
1987            return Some(truncate_field("Apache-2.0".to_string()));
1988        }
1989        if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
1990            return Some(truncate_field("MIT".to_string()));
1991        }
1992        if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
1993            return Some(truncate_field(trimmed.to_string()));
1994        }
1995    }
1996
1997    None
1998}
1999
2000crate::register_parser!(
2001    "Gradle build script",
2002    &["**/build.gradle", "**/build.gradle.kts"],
2003    "maven",
2004    "Java",
2005    Some("https://gradle.org/"),
2006);
2007
2008#[cfg(test)]
2009mod tests {
2010    use super::*;
2011    use tempfile::tempdir;
2012
2013    #[test]
2014    fn test_is_match() {
2015        assert!(GradleParser::is_match(Path::new("build.gradle")));
2016        assert!(GradleParser::is_match(Path::new("build.gradle.kts")));
2017        assert!(GradleParser::is_match(Path::new("project/build.gradle")));
2018        assert!(!GradleParser::is_match(Path::new("build.xml")));
2019        assert!(!GradleParser::is_match(Path::new("settings.gradle")));
2020    }
2021
2022    #[test]
2023    fn test_extract_simple_dependencies() {
2024        let content = r#"
2025dependencies {
2026    compile 'org.apache.commons:commons-text:1.1'
2027    testCompile 'junit:junit:4.12'
2028}
2029"#;
2030        let tokens = lex(content);
2031        let deps = extract_dependencies(&tokens);
2032        assert_eq!(deps.len(), 2);
2033
2034        let dep1 = &deps[0];
2035        assert_eq!(
2036            dep1.purl,
2037            Some("pkg:maven/org.apache.commons/commons-text@1.1".to_string())
2038        );
2039        assert_eq!(dep1.scope, Some("compile".to_string()));
2040        assert_eq!(dep1.is_runtime, Some(true));
2041        assert_eq!(dep1.is_pinned, Some(true));
2042
2043        let dep2 = &deps[1];
2044        assert_eq!(dep2.purl, Some("pkg:maven/junit/junit@4.12".to_string()));
2045        assert_eq!(dep2.scope, Some("testCompile".to_string()));
2046        assert_eq!(dep2.is_runtime, Some(false));
2047        assert_eq!(dep2.is_optional, Some(true));
2048    }
2049
2050    #[test]
2051    fn test_extract_parens_notation() {
2052        let content = r#"
2053dependencies {
2054    implementation("com.example:library:1.0.0")
2055    testImplementation("junit:junit:4.13")
2056}
2057"#;
2058        let tokens = lex(content);
2059        let deps = extract_dependencies(&tokens);
2060        assert_eq!(deps.len(), 2);
2061        assert_eq!(
2062            deps[0].purl,
2063            Some("pkg:maven/com.example/library@1.0.0".to_string())
2064        );
2065    }
2066
2067    #[test]
2068    fn test_extract_named_parameters() {
2069        let content = r#"
2070dependencies {
2071    api group: 'com.google.guava', name: 'guava', version: '30.1-jre'
2072}
2073"#;
2074        let tokens = lex(content);
2075        let deps = extract_dependencies(&tokens);
2076        assert_eq!(deps.len(), 1);
2077        assert_eq!(
2078            deps[0].purl,
2079            Some("pkg:maven/com.google.guava/guava@30.1-jre".to_string())
2080        );
2081        assert_eq!(deps[0].scope, Some("api".to_string()));
2082    }
2083
2084    #[test]
2085    fn test_multiple_dependency_blocks_all_parsed() {
2086        let content = r#"
2087dependencies {
2088    implementation 'org.scala-lang:scala-library:2.11.12'
2089}
2090
2091dependencies {
2092    implementation 'commons-collections:commons-collections:3.2.2'
2093    testImplementation 'junit:junit:4.13'
2094}
2095"#;
2096        let tokens = lex(content);
2097        let deps = extract_dependencies(&tokens);
2098        assert_eq!(deps.len(), 3);
2099        assert_eq!(
2100            deps[0].purl,
2101            Some("pkg:maven/org.scala-lang/scala-library@2.11.12".to_string())
2102        );
2103        assert_eq!(
2104            deps[1].purl,
2105            Some("pkg:maven/commons-collections/commons-collections@3.2.2".to_string())
2106        );
2107        assert_eq!(deps[2].purl, Some("pkg:maven/junit/junit@4.13".to_string()));
2108        assert_eq!(deps[2].scope, Some("testImplementation".to_string()));
2109    }
2110
2111    #[test]
2112    fn test_nested_dependency_blocks_all_parsed() {
2113        let content = r#"
2114buildscript {
2115    dependencies {
2116        classpath("org.eclipse.jgit:org.eclipse.jgit:$jgitVersion")
2117    }
2118}
2119
2120subprojects {
2121    dependencies {
2122        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinPluginVersion")
2123    }
2124}
2125"#;
2126        let tokens = lex(content);
2127        let deps = extract_dependencies(&tokens);
2128
2129        assert_eq!(deps.len(), 2);
2130        assert_eq!(
2131            deps[0].purl,
2132            Some("pkg:maven/org.eclipse.jgit/org.eclipse.jgit@%24jgitVersion".to_string())
2133        );
2134        assert_eq!(deps[0].scope, Some("classpath".to_string()));
2135        assert_eq!(
2136            deps[1].purl,
2137            Some(
2138                "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib-jdk8@%24kotlinPluginVersion"
2139                    .to_string()
2140            )
2141        );
2142        assert_eq!(deps[1].scope, Some("implementation".to_string()));
2143    }
2144
2145    #[test]
2146    fn test_no_version() {
2147        let content = r#"
2148dependencies {
2149    compile 'org.example:library'
2150}
2151"#;
2152        let tokens = lex(content);
2153        let deps = extract_dependencies(&tokens);
2154        assert_eq!(deps.len(), 1);
2155        assert_eq!(deps[0].is_pinned, Some(false));
2156        assert_eq!(deps[0].extracted_requirement, Some("".to_string()));
2157    }
2158
2159    #[test]
2160    fn test_nested_function_calls() {
2161        let content = r#"
2162dependencies {
2163    implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.12.2"))
2164    testImplementation(platform("org.junit:junit-bom:5.7.2"))
2165}
2166"#;
2167        let tokens = lex(content);
2168        let deps = extract_dependencies(&tokens);
2169        assert_eq!(deps.len(), 2);
2170        assert_eq!(
2171            deps[0].purl,
2172            Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2".to_string())
2173        );
2174        assert_eq!(deps[0].scope, Some("enforcedPlatform".to_string()));
2175        assert_eq!(deps[1].scope, Some("platform".to_string()));
2176    }
2177
2178    #[test]
2179    fn test_map_format() {
2180        let content = r#"
2181dependencies {
2182    runtimeOnly(
2183        [group: 'org.jacoco', name: 'org.jacoco.ant', version: '0.7.4.201502262128'],
2184        [group: 'org.jacoco', name: 'org.jacoco.agent', version: '0.7.4.201502262128']
2185    )
2186}
2187"#;
2188        let tokens = lex(content);
2189        let deps = extract_dependencies(&tokens);
2190        assert_eq!(deps.len(), 2);
2191        assert_eq!(deps[0].scope, Some("".to_string()));
2192        assert_eq!(
2193            deps[0].purl,
2194            Some("pkg:maven/org.jacoco.ant@0.7.4.201502262128".to_string())
2195        );
2196    }
2197
2198    #[test]
2199    fn test_bracket_map_dedupes_exact_string_overlap() {
2200        let content = r#"
2201dependencies {
2202    runtimeOnly 'org.springframework:spring-core:2.5',
2203            'org.springframework:spring-aop:2.5'
2204    runtimeOnly(
2205        [group: 'org.springframework', name: 'spring-core', version: '2.5'],
2206        [group: 'org.springframework', name: 'spring-aop', version: '2.5']
2207    )
2208}
2209"#;
2210
2211        let tokens = lex(content);
2212        let deps = extract_dependencies(&tokens);
2213        assert_eq!(deps.len(), 2);
2214        assert_eq!(
2215            deps[0].purl,
2216            Some("pkg:maven/org.springframework/spring-core@2.5".to_string())
2217        );
2218        assert_eq!(
2219            deps[1].purl,
2220            Some("pkg:maven/org.springframework/spring-aop@2.5".to_string())
2221        );
2222    }
2223
2224    #[test]
2225    fn test_malformed_string_stops_cascading_false_positives() {
2226        let content = r#"
2227dependencies {
2228    implementation "com.fasterxml.jackson:jackson-bom:2.12.2'
2229    implementation" com.fasterxml.jackson.core:jackson-core"
2230    testImplementation 'org.junit:junit-bom:5.7.2'"
2231    testImplementation "org.junit.platform:junit-platform-commons"
2232}
2233"#;
2234
2235        let tokens = lex(content);
2236        let deps = extract_dependencies(&tokens);
2237        assert_eq!(deps.len(), 1);
2238        assert_eq!(
2239            deps[0].purl,
2240            Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2%27".to_string())
2241        );
2242    }
2243
2244    #[test]
2245    fn test_project_references() {
2246        let content = r#"
2247dependencies {
2248    implementation(project(":documentation"))
2249    implementation(project(":basics"))
2250}
2251"#;
2252        let tokens = lex(content);
2253        let deps = extract_dependencies(&tokens);
2254        assert_eq!(deps.len(), 2);
2255        assert_eq!(deps[0].scope, Some("implementation".to_string()));
2256        assert_eq!(
2257            deps[0]
2258                .extra_data
2259                .as_ref()
2260                .and_then(|data| data.get("project_path"))
2261                .and_then(|value| value.as_str()),
2262            Some("documentation")
2263        );
2264        assert_eq!(deps[0].purl, Some("pkg:maven/documentation".to_string()));
2265        assert_eq!(deps[1].scope, Some("implementation".to_string()));
2266        assert_eq!(
2267            deps[1]
2268                .extra_data
2269                .as_ref()
2270                .and_then(|data| data.get("project_path"))
2271                .and_then(|value| value.as_str()),
2272            Some("basics")
2273        );
2274        assert_eq!(deps[1].purl, Some("pkg:maven/basics".to_string()));
2275    }
2276
2277    #[test]
2278    fn test_nested_project_references_preserve_parent_path() {
2279        let content = r#"
2280dependencies {
2281    implementation(project(":libs:download"))
2282    implementation(project(":libs:index"))
2283}
2284"#;
2285        let tokens = lex(content);
2286        let deps = extract_dependencies(&tokens);
2287
2288        assert_eq!(deps.len(), 2);
2289        assert_eq!(deps[0].purl, Some("pkg:maven/libs/download".to_string()));
2290        assert_eq!(deps[0].scope, Some("implementation".to_string()));
2291        assert_eq!(
2292            deps[0]
2293                .extra_data
2294                .as_ref()
2295                .and_then(|data| data.get("project_path"))
2296                .and_then(|value| value.as_str()),
2297            Some("libs:download")
2298        );
2299        assert_eq!(deps[1].scope, Some("implementation".to_string()));
2300        assert_eq!(
2301            deps[1]
2302                .extra_data
2303                .as_ref()
2304                .and_then(|data| data.get("project_path"))
2305                .and_then(|value| value.as_str()),
2306            Some("libs:index")
2307        );
2308        assert_eq!(deps[1].purl, Some("pkg:maven/libs/index".to_string()));
2309    }
2310
2311    #[test]
2312    fn test_testimplementation_project_reference_is_not_runtime() {
2313        let content = r#"
2314dependencies {
2315    testImplementation project(':mockito-config')
2316}
2317"#;
2318        let tokens = lex(content);
2319        let deps = extract_dependencies(&tokens);
2320
2321        assert_eq!(deps.len(), 1);
2322        assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2323        assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2324        assert_eq!(deps[0].is_runtime, Some(false));
2325        assert_eq!(deps[0].is_optional, Some(true));
2326        assert_eq!(
2327            deps[0]
2328                .extra_data
2329                .as_ref()
2330                .and_then(|data| data.get("project_path"))
2331                .and_then(|value| value.as_str()),
2332            Some("mockito-config")
2333        );
2334    }
2335
2336    #[test]
2337    fn test_unresolved_dotted_identifiers_are_ignored_but_project_refs_survive() {
2338        let content = r#"
2339dependencies {
2340    implementation Deps.AndroidX.core
2341    implementation Deps.AndroidX.androidxAnnotation
2342    testImplementation TestDeps.mockitoCore3
2343    testImplementation project(':mockito-config')
2344}
2345"#;
2346        let tokens = lex(content);
2347        let deps = extract_dependencies(&tokens);
2348
2349        assert_eq!(deps.len(), 1);
2350        assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2351        assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2352        assert_eq!(deps[0].is_runtime, Some(false));
2353        assert_eq!(deps[0].is_optional, Some(true));
2354        assert_eq!(
2355            deps[0]
2356                .extra_data
2357                .as_ref()
2358                .and_then(|data| data.get("project_path"))
2359                .and_then(|value| value.as_str()),
2360            Some("mockito-config")
2361        );
2362    }
2363
2364    #[test]
2365    fn test_buildsrc_kotlin_constants_resolve_from_committed_files() {
2366        let temp_dir = tempdir().unwrap();
2367        let build_src_dir = temp_dir
2368            .path()
2369            .join("buildSrc/src/main/java/com/example/buildsrc");
2370        std::fs::create_dir_all(&build_src_dir).unwrap();
2371        std::fs::write(
2372            build_src_dir.join("GradleDeps.kt"),
2373            r#"
2374object GradleDeps {
2375    object Kotlin {
2376        const val version = "2.0.0"
2377        const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version"
2378    }
2379}
2380"#,
2381        )
2382        .unwrap();
2383        std::fs::write(
2384            build_src_dir.join("Deps.kt"),
2385            r#"
2386object Deps {
2387    object AndroidX {
2388        const val core = "androidx.core:core:1.15.0"
2389    }
2390
2391    object SoLoader {
2392        private const val version = "0.11.0"
2393        const val soloader = "com.facebook.soloader:soloader:$version"
2394    }
2395}
2396"#,
2397        )
2398        .unwrap();
2399        std::fs::write(
2400            build_src_dir.join("TestDeps.kt"),
2401            r#"
2402object TestDeps {
2403    const val junit = "junit:junit:4.13.2"
2404}
2405"#,
2406        )
2407        .unwrap();
2408
2409        let build_gradle = temp_dir.path().join("build.gradle");
2410        std::fs::write(
2411            &build_gradle,
2412            r#"
2413buildscript {
2414    dependencies {
2415        classpath GradleDeps.Kotlin.gradlePlugin
2416    }
2417}
2418
2419dependencies {
2420    implementation Deps.AndroidX.core
2421    implementation Deps.SoLoader.soloader
2422    implementation project(':fbcore')
2423    testImplementation(TestDeps.junit) {
2424        because 'exercise parenthesized symbolic refs'
2425    }
2426}
2427"#,
2428        )
2429        .unwrap();
2430
2431        let package_data = GradleParser::extract_first_package(&build_gradle);
2432
2433        assert_eq!(package_data.dependencies.len(), 5);
2434        assert!(package_data.dependencies.iter().any(|dependency| {
2435            dependency.purl.as_deref()
2436                == Some("pkg:maven/org.jetbrains.kotlin/kotlin-gradle-plugin@2.0.0")
2437                && dependency.scope.as_deref() == Some("classpath")
2438        }));
2439        assert!(package_data.dependencies.iter().any(|dependency| {
2440            dependency.purl.as_deref() == Some("pkg:maven/androidx.core/core@1.15.0")
2441                && dependency.scope.as_deref() == Some("implementation")
2442        }));
2443        assert!(package_data.dependencies.iter().any(|dependency| {
2444            dependency.purl.as_deref() == Some("pkg:maven/com.facebook.soloader/soloader@0.11.0")
2445                && dependency.scope.as_deref() == Some("implementation")
2446        }));
2447        assert!(package_data.dependencies.iter().any(|dependency| {
2448            dependency.purl.as_deref() == Some("pkg:maven/fbcore")
2449                && dependency.scope.as_deref() == Some("implementation")
2450        }));
2451        assert!(package_data.dependencies.iter().any(|dependency| {
2452            dependency.purl.as_deref() == Some("pkg:maven/junit/junit@4.13.2")
2453                && dependency.scope.as_deref() == Some("testImplementation")
2454                && dependency.is_runtime == Some(false)
2455                && dependency.is_optional == Some(true)
2456        }));
2457    }
2458
2459    #[test]
2460    fn test_gradle_properties_and_local_assignments_resolve_interpolation() {
2461        let temp_dir = tempdir().unwrap();
2462        std::fs::write(
2463            temp_dir.path().join("gradle.properties"),
2464            "ktorVersion=2.3.10\nkotlinVersion=2.0.0\n",
2465        )
2466        .unwrap();
2467        let build_gradle = temp_dir.path().join("build.gradle.kts");
2468        std::fs::write(
2469            &build_gradle,
2470            r#"
2471val ktorVersion: String by project
2472val kotlinVersion = "2.1.0"
2473
2474dependencies {
2475    implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
2476    testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
2477}
2478"#,
2479        )
2480        .unwrap();
2481
2482        let package_data = GradleParser::extract_first_package(&build_gradle);
2483        assert_eq!(package_data.dependencies.len(), 2);
2484        assert!(package_data.dependencies.iter().any(|dependency| {
2485            dependency.purl.as_deref() == Some("pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@2.1.0")
2486                && dependency.extracted_requirement.as_deref() == Some("2.1.0")
2487                && dependency.scope.as_deref() == Some("implementation")
2488        }));
2489        assert!(package_data.dependencies.iter().any(|dependency| {
2490            dependency.purl.as_deref() == Some("pkg:maven/io.ktor/ktor-server-test-host@2.3.10")
2491                && dependency.extracted_requirement.as_deref() == Some("2.3.10")
2492                && dependency.scope.as_deref() == Some("testImplementation")
2493        }));
2494    }
2495
2496    #[test]
2497    fn test_conditional_dependencies_inside_if_blocks_are_extracted() {
2498        let temp_dir = tempdir().unwrap();
2499        let build_gradle = temp_dir.path().join("build.gradle");
2500        std::fs::write(
2501            &build_gradle,
2502            r#"
2503def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
2504
2505dependencies {
2506    implementation("com.facebook.react:react-android")
2507
2508    if (hermesEnabled.toBoolean()) {
2509        implementation("com.facebook.react:hermes-android")
2510    } else {
2511        implementation jscFlavor
2512    }
2513}
2514"#,
2515        )
2516        .unwrap();
2517
2518        let package_data = GradleParser::extract_first_package(&build_gradle);
2519
2520        assert!(package_data.dependencies.iter().any(|dependency| {
2521            dependency.purl.as_deref() == Some("pkg:maven/com.facebook.react/react-android")
2522                && dependency.scope.as_deref() == Some("implementation")
2523        }));
2524        assert!(package_data.dependencies.iter().any(|dependency| {
2525            dependency.purl.as_deref() == Some("pkg:maven/com.facebook.react/hermes-android")
2526                && dependency.scope.as_deref() == Some("implementation")
2527        }));
2528    }
2529
2530    #[test]
2531    fn test_compile_only_is_not_runtime() {
2532        let content = r#"
2533dependencies {
2534    compileOnly 'org.antlr:antlr:2.7.7'
2535    compileOnlyApi 'com.example:annotations:1.0.0'
2536    testCompileOnly 'junit:junit:4.13'
2537}
2538"#;
2539        let tokens = lex(content);
2540        let deps = extract_dependencies(&tokens);
2541
2542        assert_eq!(deps.len(), 3);
2543        assert_eq!(deps[0].scope, Some("compileOnly".to_string()));
2544        assert_eq!(deps[0].is_runtime, Some(false));
2545        assert_eq!(deps[0].is_optional, Some(false));
2546
2547        assert_eq!(deps[1].scope, Some("compileOnlyApi".to_string()));
2548        assert_eq!(deps[1].is_runtime, Some(false));
2549        assert_eq!(deps[1].is_optional, Some(false));
2550
2551        assert_eq!(deps[2].scope, Some("testCompileOnly".to_string()));
2552        assert_eq!(deps[2].is_runtime, Some(false));
2553        assert_eq!(deps[2].is_optional, Some(true));
2554    }
2555
2556    #[test]
2557    fn test_version_catalog_alias_resolution_from_libs_versions_toml() {
2558        let temp_dir = tempdir().unwrap();
2559        let gradle_dir = temp_dir.path().join("gradle");
2560        std::fs::create_dir_all(&gradle_dir).unwrap();
2561
2562        std::fs::write(
2563            gradle_dir.join("libs.versions.toml"),
2564            r#"
2565[versions]
2566androidxAppcompat = "1.7.0"
2567
2568[libraries]
2569androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2570guardianproject-panic = { group = "info.guardianproject", name = "panic", version = "1.0.0" }
2571"#,
2572        )
2573        .unwrap();
2574
2575        let build_gradle = temp_dir.path().join("build.gradle");
2576        std::fs::write(
2577            &build_gradle,
2578            r#"
2579dependencies {
2580    implementation libs.androidx.appcompat
2581    fullImplementation libs.guardianproject.panic
2582}
2583"#,
2584        )
2585        .unwrap();
2586
2587        let package_data = GradleParser::extract_first_package(&build_gradle);
2588
2589        assert_eq!(package_data.dependencies.len(), 2);
2590        assert_eq!(
2591            package_data.dependencies[0].purl,
2592            Some("pkg:maven/androidx.appcompat/appcompat@1.7.0".to_string())
2593        );
2594        assert_eq!(
2595            package_data.dependencies[0].scope,
2596            Some("implementation".to_string())
2597        );
2598        assert_eq!(
2599            package_data.dependencies[1].purl,
2600            Some("pkg:maven/info.guardianproject/panic@1.0.0".to_string())
2601        );
2602        assert_eq!(
2603            package_data.dependencies[1].scope,
2604            Some("fullImplementation".to_string())
2605        );
2606    }
2607
2608    #[test]
2609    fn test_extract_gradle_license_metadata_from_pom_block() {
2610        let content = r#"
2611plugins {
2612    id 'java-library'
2613    id 'maven'
2614}
2615
2616dependencies {
2617    api 'org.apache.commons:commons-text:1.1'
2618}
2619
2620configure(install.repositories.mavenInstaller) {
2621    pom.project {
2622        licenses {
2623            license {
2624                name 'The Apache License, Version 2.0'
2625                url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
2626            }
2627        }
2628    }
2629}
2630"#;
2631
2632        let temp_dir = tempdir().unwrap();
2633        let build_gradle = temp_dir.path().join("build.gradle");
2634        std::fs::write(&build_gradle, content).unwrap();
2635
2636        let package_data = GradleParser::extract_first_package(&build_gradle);
2637
2638        assert_eq!(
2639            package_data.extracted_license_statement,
2640            Some(
2641                "- license:\n    name: The Apache License, Version 2.0\n    url: http://www.apache.org/licenses/LICENSE-2.0.txt\n"
2642                    .to_string()
2643            )
2644        );
2645        assert_eq!(
2646            package_data.declared_license_expression_spdx,
2647            Some("Apache-2.0".to_string())
2648        );
2649    }
2650
2651    #[test]
2652    fn test_parse_gradle_version_catalog_helper() {
2653        let temp_dir = tempdir().unwrap();
2654        let catalog_path = temp_dir.path().join("libs.versions.toml");
2655        std::fs::write(
2656            &catalog_path,
2657            r#"
2658[versions]
2659androidxAppcompat = "1.7.0"
2660
2661[libraries]
2662androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2663"#,
2664        )
2665        .unwrap();
2666
2667        let entries = parse_gradle_version_catalog(&catalog_path).unwrap();
2668        let entry = entries.get("androidx.appcompat").unwrap();
2669
2670        assert_eq!(entry.namespace, "androidx.appcompat");
2671        assert_eq!(entry.name, "appcompat");
2672        assert_eq!(entry.version.as_deref(), Some("1.7.0"));
2673    }
2674
2675    #[test]
2676    fn test_string_interpolation() {
2677        let content = r#"
2678dependencies {
2679    compile "com.amazonaws:aws-java-sdk-core:${awsVer}"
2680}
2681"#;
2682        let tokens = lex(content);
2683        let deps = extract_dependencies(&tokens);
2684        assert_eq!(deps.len(), 1);
2685        assert_eq!(deps[0].extracted_requirement, Some("${awsVer}".to_string()));
2686        assert_eq!(
2687            deps[0].purl,
2688            Some("pkg:maven/com.amazonaws/aws-java-sdk-core@%24%7BawsVer%7D".to_string())
2689        );
2690    }
2691
2692    #[test]
2693    fn test_multi_value_string_notation() {
2694        let content = r#"
2695dependencies {
2696    runtimeOnly 'org.springframework:spring-core:2.5',
2697            'org.springframework:spring-aop:2.5'
2698}
2699"#;
2700        let tokens = lex(content);
2701        let deps = extract_dependencies(&tokens);
2702        assert_eq!(deps.len(), 2);
2703        assert_eq!(deps[0].scope, Some("".to_string()));
2704        assert_eq!(deps[1].scope, Some("".to_string()));
2705    }
2706
2707    #[test]
2708    fn test_kotlin_quoted_scope_string_dependency_extracted() {
2709        let content = r#"
2710dependencies {
2711    "js"("jquery:jquery:3.2.1@js")
2712}
2713"#;
2714        let tokens = lex(content);
2715        let deps = extract_dependencies(&tokens);
2716        assert_eq!(deps.len(), 1);
2717        assert_eq!(deps[0].scope, Some("js".to_string()));
2718        assert_eq!(
2719            deps[0].purl,
2720            Some("pkg:maven/jquery/jquery@3.2.1%40js".to_string())
2721        );
2722    }
2723
2724    #[test]
2725    fn test_kotlin_quoted_scope_project_reference_extracted() {
2726        let content = r#"
2727subprojects {
2728    dependencies {
2729        "testImplementation"(project(":utils:test-utils"))
2730    }
2731}
2732"#;
2733        let tokens = lex(content);
2734        let deps = extract_dependencies(&tokens);
2735        assert_eq!(deps.len(), 1);
2736        assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2737        assert_eq!(deps[0].purl, Some("pkg:maven/utils/test-utils".to_string()));
2738        assert_eq!(deps[0].is_runtime, Some(false));
2739        assert_eq!(deps[0].is_optional, Some(true));
2740        assert_eq!(
2741            deps[0]
2742                .extra_data
2743                .as_ref()
2744                .and_then(|data| data.get("project_path"))
2745                .and_then(|value| value.as_str()),
2746            Some("utils:test-utils")
2747        );
2748    }
2749
2750    #[test]
2751    fn test_kotlin_quoted_scope_string_dependency_with_closure_extracted() {
2752        let content = r#"
2753dependencies {
2754    "implementation"("com.badlogicgames.gdx:gdx-tools:1.14.0") {
2755        exclude("com.badlogicgames.gdx", "gdx-backend-lwjgl")
2756    }
2757}
2758"#;
2759        let tokens = lex(content);
2760        let deps = extract_dependencies(&tokens);
2761        assert_eq!(deps.len(), 1);
2762        assert_eq!(deps[0].scope, Some("implementation".to_string()));
2763        assert_eq!(
2764            deps[0].purl,
2765            Some("pkg:maven/com.badlogicgames.gdx/gdx-tools@1.14.0".to_string())
2766        );
2767    }
2768
2769    #[test]
2770    fn test_closure_after_dependency() {
2771        let content = r#"
2772dependencies {
2773    runtimeOnly('org.hibernate:hibernate:3.0.5') {
2774        transitive = true
2775    }
2776}
2777"#;
2778        let tokens = lex(content);
2779        let deps = extract_dependencies(&tokens);
2780        assert_eq!(deps.len(), 1);
2781        assert_eq!(
2782            deps[0].purl,
2783            Some("pkg:maven/org.hibernate/hibernate@3.0.5".to_string())
2784        );
2785        assert_eq!(deps[0].scope, Some("runtimeOnly".to_string()));
2786    }
2787}