Skip to main content

provenant/parsers/
gradle.rs

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