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