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