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::path::Path;
28
29use crate::parser_warn as warn;
30use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
31
32const MAX_RECURSION_DEPTH: usize = 50;
33use packageurl::PackageUrl;
34use serde_json::json;
35
36use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
37use crate::parsers::PackageParser;
38
39use super::license_normalization::{
40    DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
41    normalize_spdx_expression,
42};
43
44/// Parses Gradle build files (build.gradle, build.gradle.kts).
45///
46/// Extracts dependencies from Gradle build scripts using a custom
47/// token-based lexer and recursive descent parser. Supports both
48/// Groovy and Kotlin DSL syntax.
49///
50/// # Supported Patterns
51/// - String notation: `implementation 'group:name:version'`
52/// - Named parameters: `implementation group: 'x', name: 'y', version: 'z'`
53/// - Map format: `implementation([group: 'x', name: 'y'])`
54/// - Nested functions: `implementation(enforcedPlatform("..."))`
55/// - Project references: `implementation(project(":module"))`
56/// - String interpolation: `implementation("group:name:${version}")`
57///
58/// # Implementation
59/// Uses a custom token-based lexer (870 lines) instead of tree-sitter for:
60/// - Lighter binary size (no external parser dependencies)
61/// - Easier maintenance for DSL-specific quirks
62/// - Better error messages for malformed input
63///
64/// # Example
65/// ```no_run
66/// use provenant::parsers::{GradleParser, PackageParser};
67/// use std::path::Path;
68///
69/// let path = Path::new("testdata/gradle-golden/groovy1/build.gradle");
70/// let package_data = GradleParser::extract_first_package(path);
71/// assert!(!package_data.dependencies.is_empty());
72/// ```
73pub struct GradleParser;
74
75impl PackageParser for GradleParser {
76    const PACKAGE_TYPE: PackageType = PackageType::Maven;
77
78    fn is_match(path: &Path) -> bool {
79        path.file_name().is_some_and(|name| {
80            let name_str = name.to_string_lossy();
81            name_str == "build.gradle" || name_str == "build.gradle.kts"
82        })
83    }
84
85    fn extract_packages(path: &Path) -> Vec<PackageData> {
86        let content = match read_file_to_string(path, None) {
87            Ok(c) => c,
88            Err(e) => {
89                warn!("Failed to read {:?}: {}", path, e);
90                return vec![default_package_data()];
91            }
92        };
93
94        let tokens = lex(&content);
95        let mut dependencies = extract_dependencies(&tokens);
96        resolve_gradle_version_catalog_aliases(path, &mut dependencies);
97        let (
98            extracted_license_statement,
99            declared_license_expression,
100            declared_license_expression_spdx,
101            license_detections,
102        ) = extract_gradle_license_metadata(&tokens);
103
104        vec![PackageData {
105            package_type: Some(Self::PACKAGE_TYPE),
106            namespace: None,
107            name: None,
108            version: None,
109            qualifiers: None,
110            subpath: None,
111            primary_language: None,
112            description: None,
113            release_date: None,
114            parties: Vec::new(),
115            keywords: Vec::new(),
116            homepage_url: None,
117            download_url: None,
118            size: None,
119            sha1: None,
120            md5: None,
121            sha256: None,
122            sha512: None,
123            bug_tracking_url: None,
124            code_view_url: None,
125            vcs_url: None,
126            copyright: None,
127            holder: None,
128            declared_license_expression,
129            declared_license_expression_spdx,
130            license_detections,
131            other_license_expression: None,
132            other_license_expression_spdx: None,
133            other_license_detections: Vec::new(),
134            extracted_license_statement,
135            notice_text: None,
136            source_packages: Vec::new(),
137            file_references: Vec::new(),
138            extra_data: None,
139            dependencies,
140            repository_homepage_url: None,
141            repository_download_url: None,
142            api_data_url: None,
143            datasource_id: Some(DatasourceId::BuildGradle),
144            purl: None,
145            is_private: false,
146            is_virtual: false,
147        }]
148    }
149}
150
151fn default_package_data() -> PackageData {
152    PackageData {
153        package_type: Some(GradleParser::PACKAGE_TYPE),
154        datasource_id: Some(DatasourceId::BuildGradle),
155        ..Default::default()
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Lexer
161// ---------------------------------------------------------------------------
162
163#[derive(Debug, Clone, PartialEq)]
164enum Tok {
165    Ident(String),
166    Str(String),
167    MalformedStr(String),
168    OpenParen,
169    CloseParen,
170    OpenBracket,
171    CloseBracket,
172    OpenBrace,
173    CloseBrace,
174    Colon,
175    Comma,
176    Equals,
177}
178
179fn lex(input: &str) -> Vec<Tok> {
180    let chars: Vec<char> = input.chars().collect();
181    let len = chars.len();
182    let mut i = 0;
183    let mut tokens = Vec::new();
184
185    while i < len {
186        if tokens.len() >= MAX_ITERATION_COUNT {
187            warn!(
188                "Lexer exceeded MAX_ITERATION_COUNT ({}) tokens, stopping",
189                MAX_ITERATION_COUNT
190            );
191            break;
192        }
193        let c = chars[i];
194
195        if c == '/' && i + 1 < len && chars[i + 1] == '/' {
196            while i < len && chars[i] != '\n' {
197                i += 1;
198            }
199            continue;
200        }
201
202        if c == '/' && i + 1 < len && chars[i + 1] == '*' {
203            i += 2;
204            while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
205                i += 1;
206            }
207            i += 2;
208            continue;
209        }
210
211        if c.is_whitespace() {
212            i += 1;
213            continue;
214        }
215
216        if c == '\'' {
217            i += 1;
218            let start = i;
219            while i < len && chars[i] != '\'' && chars[i] != '\n' {
220                i += 1;
221            }
222            let val: String = chars[start..i].iter().collect();
223            let val = truncate_field(val);
224            if i < len && chars[i] == '\'' {
225                tokens.push(Tok::Str(val));
226                i += 1;
227            } else {
228                tokens.push(Tok::MalformedStr(val));
229            }
230            continue;
231        }
232
233        if c == '"' {
234            i += 1;
235            let start = i;
236            while i < len && chars[i] != '"' && chars[i] != '\n' {
237                if chars[i] == '\\' && i + 1 < len {
238                    i += 2;
239                } else {
240                    i += 1;
241                }
242            }
243            let val: String = chars[start..i].iter().collect();
244            let val = truncate_field(val);
245            if i < len && chars[i] == '"' {
246                tokens.push(Tok::Str(val));
247                i += 1;
248            } else {
249                tokens.push(Tok::MalformedStr(val));
250            }
251            continue;
252        }
253
254        match c {
255            '(' => {
256                tokens.push(Tok::OpenParen);
257                i += 1;
258            }
259            ')' => {
260                tokens.push(Tok::CloseParen);
261                i += 1;
262            }
263            '[' => {
264                tokens.push(Tok::OpenBracket);
265                i += 1;
266            }
267            ']' => {
268                tokens.push(Tok::CloseBracket);
269                i += 1;
270            }
271            '{' => {
272                tokens.push(Tok::OpenBrace);
273                i += 1;
274            }
275            '}' => {
276                tokens.push(Tok::CloseBrace);
277                i += 1;
278            }
279            ':' => {
280                tokens.push(Tok::Colon);
281                i += 1;
282            }
283            ',' => {
284                tokens.push(Tok::Comma);
285                i += 1;
286            }
287            '=' => {
288                tokens.push(Tok::Equals);
289                i += 1;
290            }
291            _ if is_ident_start(c) => {
292                let start = i;
293                while i < len && is_ident_char(chars[i]) {
294                    i += 1;
295                }
296                let val: String = chars[start..i].iter().collect();
297                tokens.push(Tok::Ident(truncate_field(val)));
298            }
299            _ => {
300                i += 1;
301            }
302        }
303    }
304
305    tokens
306}
307
308fn is_ident_start(c: char) -> bool {
309    c.is_ascii_alphanumeric() || c == '_' || c == '-'
310}
311
312fn is_ident_char(c: char) -> bool {
313    c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '$'
314}
315
316// ---------------------------------------------------------------------------
317// Dependency block extraction
318// ---------------------------------------------------------------------------
319
320fn find_dependency_blocks(tokens: &[Tok]) -> Vec<Vec<Tok>> {
321    let mut blocks = Vec::new();
322    let mut i = 0;
323
324    while i < tokens.len() {
325        if let Tok::Ident(ref name) = tokens[i]
326            && name == "dependencies"
327            && i + 1 < tokens.len()
328            && tokens[i + 1] == Tok::OpenBrace
329        {
330            i += 2;
331            let mut depth = 1;
332            let start = i;
333            while i < tokens.len() && depth > 0 {
334                match &tokens[i] {
335                    Tok::OpenBrace => {
336                        depth += 1;
337                        if depth > MAX_RECURSION_DEPTH {
338                            warn!(
339                                "Gradle parser: nesting depth exceeded {} in find_dependency_blocks",
340                                MAX_RECURSION_DEPTH
341                            );
342                            break;
343                        }
344                    }
345                    Tok::CloseBrace => depth -= 1,
346                    _ => {}
347                }
348                if depth > 0 {
349                    i += 1;
350                }
351            }
352            blocks.push(tokens[start..i].to_vec());
353            if i < tokens.len() {
354                i += 1;
355            }
356            continue;
357        }
358        i += 1;
359    }
360
361    blocks
362}
363
364// ---------------------------------------------------------------------------
365// Dependency extraction from blocks
366// ---------------------------------------------------------------------------
367
368#[derive(Debug, Clone, PartialEq, Eq, Hash)]
369struct RawDep {
370    namespace: String,
371    name: String,
372    version: String,
373    scope: String,
374    catalog_alias: Option<String>,
375    project_path: Option<String>,
376}
377
378fn extract_dependencies(tokens: &[Tok]) -> Vec<Dependency> {
379    let blocks = find_dependency_blocks(tokens);
380    let mut dependencies = Vec::new();
381
382    for block in blocks {
383        for rd in parse_block(&block).into_iter().take(MAX_ITERATION_COUNT) {
384            if rd.name.is_empty() {
385                continue;
386            }
387            if let Some(dep) = create_dependency(&rd) {
388                dependencies.push(dep);
389            }
390        }
391    }
392
393    dependencies
394}
395
396fn parse_block(tokens: &[Tok]) -> Vec<RawDep> {
397    let mut deps = Vec::new();
398    let mut i = 0;
399    let mut iterations = 0;
400
401    while i < tokens.len() {
402        iterations += 1;
403        if iterations > MAX_ITERATION_COUNT {
404            warn!(
405                "parse_block exceeded MAX_ITERATION_COUNT ({}) iterations, stopping",
406                MAX_ITERATION_COUNT
407            );
408            break;
409        }
410        // Skip nested blocks (closures like `{ transitive = true }`)
411        if tokens[i] == Tok::OpenBrace {
412            let mut depth = 1;
413            i += 1;
414            while i < tokens.len() && depth > 0 {
415                match &tokens[i] {
416                    Tok::OpenBrace => {
417                        depth += 1;
418                        if depth > MAX_RECURSION_DEPTH {
419                            warn!(
420                                "Gradle parser: nesting depth exceeded {} in parse_block",
421                                MAX_RECURSION_DEPTH
422                            );
423                            break;
424                        }
425                    }
426                    Tok::CloseBrace => depth -= 1,
427                    _ => {}
428                }
429                i += 1;
430            }
431            continue;
432        }
433
434        if let Tok::Str(_) = &tokens[i]
435            && i + 1 < tokens.len()
436            && tokens[i + 1] == Tok::OpenParen
437            && let Some(end) = find_matching_paren(tokens, i + 1)
438        {
439            let inner = &tokens[i + 2..end];
440            if let Some(Tok::Ident(inner_fn)) = inner.first()
441                && inner_fn == "project"
442                && inner.len() > 1
443                && inner[1] == Tok::OpenParen
444                && let Some(project_end) = find_matching_paren(inner, 1)
445            {
446                let project_tokens = &inner[2..project_end];
447                if let Some(rd) = parse_project_ref(project_tokens) {
448                    deps.push(rd);
449                }
450                i = end + 1;
451                continue;
452            }
453        }
454
455        let scope_name = match &tokens[i] {
456            Tok::Ident(name) => name.clone(),
457            _ => {
458                i += 1;
459                continue;
460            }
461        };
462
463        if is_skip_keyword(&scope_name) {
464            i += 1;
465            continue;
466        }
467
468        let next = i + 1;
469
470        // PATTERN: scope ( ... )  — parenthesized dependency
471        if next < tokens.len() && tokens[next] == Tok::OpenParen {
472            let paren_end = find_matching_paren(tokens, next);
473            if let Some(end) = paren_end {
474                let inner = &tokens[next + 1..end];
475                parse_paren_content(&scope_name, inner, &mut deps);
476                i = end + 1;
477                continue;
478            }
479        }
480
481        // PATTERN: scope group: ..., name: ..., version: ... (named params without parens)
482        if next < tokens.len()
483            && let Tok::Ident(ref label) = tokens[next]
484            && label == "group"
485            && next + 1 < tokens.len()
486            && tokens[next + 1] == Tok::Colon
487            && let Some((rd, consumed)) = parse_named_params(&scope_name, &tokens[next..])
488        {
489            deps.push(rd);
490            i = next + consumed;
491            continue;
492        }
493
494        // PATTERN: scope 'string:notation' (string notation)
495        if next < tokens.len()
496            && matches!(
497                tokens.get(next),
498                Some(Tok::Str(_)) | Some(Tok::MalformedStr(_))
499            )
500        {
501            let (val, is_malformed) = match &tokens[next] {
502                Tok::Str(val) => (val.as_str(), false),
503                Tok::MalformedStr(val) => (val.as_str(), true),
504                _ => unreachable!(),
505            };
506
507            if !val.contains(':') {
508                i = next + 1;
509                continue;
510            }
511
512            if val.chars().next().is_some_and(|c| c.is_whitespace()) {
513                break;
514            }
515
516            // `scope 'str', { closure }` → skip (unparenthesized call with trailing closure)
517            if next + 1 < tokens.len()
518                && tokens[next + 1] == Tok::Comma
519                && next + 2 < tokens.len()
520                && tokens[next + 2] == Tok::OpenBrace
521            {
522                i = next + 1;
523                continue;
524            }
525            let is_multi = i + 2 < tokens.len()
526                && tokens[next + 1] == Tok::Comma
527                && matches!(tokens.get(next + 2), Some(Tok::Str(_)));
528            let effective_scope = if is_multi { "" } else { &scope_name };
529            let rd = parse_colon_string(val, effective_scope);
530            deps.push(rd);
531            if is_malformed {
532                break;
533            }
534            i = next + 1;
535            while i < tokens.len() && tokens[i] == Tok::Comma {
536                i += 1;
537                if i < tokens.len()
538                    && let Tok::Str(ref v2) = tokens[i]
539                    && v2.contains(':')
540                {
541                    deps.push(parse_colon_string(v2, ""));
542                    i += 1;
543                    continue;
544                }
545                break;
546            }
547            continue;
548        }
549
550        // PATTERN: scope ident.attr (variable reference / dotted identifier)
551        // Note: Skip references starting with "dependencies." as Python's pygmars
552        // relabels the "dependencies" token, breaking the DEPENDENCY-5 grammar rule.
553        if next < tokens.len()
554            && let Tok::Ident(ref val) = tokens[next]
555            && val.contains('.')
556            && !val.starts_with("dependencies.")
557            && let Some(last_seg) = val.rsplit('.').next()
558            && !last_seg.is_empty()
559        {
560            deps.push(RawDep {
561                namespace: String::new(),
562                name: truncate_field(last_seg.to_string()),
563                version: String::new(),
564                scope: truncate_field(scope_name.clone()),
565                catalog_alias: val
566                    .strip_prefix("libs.")
567                    .map(|alias| truncate_field(alias.to_string())),
568                project_path: None,
569            });
570            i = next + 1;
571            continue;
572        }
573
574        // PATTERN: scope project(':module') — project reference without parens
575        if next < tokens.len()
576            && let Tok::Ident(ref name) = tokens[next]
577            && name == "project"
578            && next + 1 < tokens.len()
579            && tokens[next + 1] == Tok::OpenParen
580            && let Some(end) = find_matching_paren(tokens, next + 1)
581        {
582            let inner = &tokens[next + 2..end];
583            if let Some(rd) = parse_project_ref(inner) {
584                deps.push(rd);
585            }
586            i = end + 1;
587            continue;
588        }
589
590        i += 1;
591    }
592
593    deps
594}
595
596fn is_skip_keyword(name: &str) -> bool {
597    matches!(
598        name,
599        "plugins"
600            | "apply"
601            | "ext"
602            | "configurations"
603            | "repositories"
604            | "subprojects"
605            | "allprojects"
606            | "buildscript"
607            | "pluginManager"
608            | "publishing"
609            | "sourceSets"
610            | "tasks"
611            | "task"
612    )
613}
614
615fn parse_paren_content(scope: &str, tokens: &[Tok], deps: &mut Vec<RawDep>) {
616    if tokens.is_empty() {
617        return;
618    }
619
620    // Check for bracket-enclosed maps: [group: ..., name: ..., version: ...]
621    if tokens[0] == Tok::OpenBracket {
622        parse_bracket_maps(tokens, deps);
623        return;
624    }
625
626    // Check for named parameters: group: 'x' or group = "x"
627    if let Some(Tok::Ident(label)) = tokens.first()
628        && label == "group"
629        && tokens.len() > 1
630        && tokens[1] == Tok::Colon
631    {
632        if let Some((rd, _)) = parse_named_params("", tokens) {
633            deps.push(rd);
634        }
635        return;
636    }
637
638    // Check for nested function call or project reference
639    if let Some(Tok::Ident(inner_fn)) = tokens.first()
640        && tokens.len() > 1
641        && tokens[1] == Tok::OpenParen
642    {
643        if inner_fn == "project" {
644            if let Some(end) = find_matching_paren(tokens, 1) {
645                let inner = &tokens[2..end];
646                if let Some(rd) = parse_project_ref(inner) {
647                    deps.push(rd);
648                }
649            }
650            return;
651        }
652
653        if let Some(end) = find_matching_paren(tokens, 1) {
654            let inner = &tokens[2..end];
655            if let Some(Tok::Str(val)) = inner.first()
656                && val.contains(':')
657            {
658                deps.push(parse_colon_string(val, inner_fn));
659                return;
660            }
661        }
662    }
663
664    // Simple string: ("g:n:v")
665    if let Some(Tok::Str(val)) = tokens.first()
666        && val.contains(':')
667    {
668        deps.push(parse_colon_string(val, scope));
669    }
670}
671
672fn parse_bracket_maps(tokens: &[Tok], deps: &mut Vec<RawDep>) {
673    let mut i = 0;
674    while i < tokens.len() {
675        if tokens[i] == Tok::OpenBracket
676            && let Some(end) = find_matching_bracket(tokens, i)
677        {
678            let map_tokens = &tokens[i + 1..end];
679            if let Some(rd) = parse_map_entries(map_tokens)
680                && !contains_equivalent_map_dep(deps, &rd)
681            {
682                deps.push(rd);
683            }
684            i = end + 1;
685            continue;
686        }
687        i += 1;
688    }
689}
690
691fn contains_equivalent_map_dep(existing: &[RawDep], candidate: &RawDep) -> bool {
692    existing.iter().any(|dep| {
693        dep.name == candidate.name
694            && dep.version == candidate.version
695            && dep.scope == candidate.scope
696            && (dep.namespace == candidate.namespace
697                || dep.namespace.is_empty()
698                || candidate.namespace.is_empty())
699    })
700}
701
702fn parse_map_entries(tokens: &[Tok]) -> Option<RawDep> {
703    let mut name = String::new();
704    let mut version = String::new();
705    let mut i = 0;
706
707    while i < tokens.len() {
708        if let Tok::Ident(ref label) = tokens[i]
709            && i + 2 < tokens.len()
710            && tokens[i + 1] == Tok::Colon
711            && let Tok::Str(ref val) = tokens[i + 2]
712        {
713            match label.as_str() {
714                "name" => name = truncate_field(val.clone()),
715                "version" => version = truncate_field(val.clone()),
716                _ => {}
717            }
718            i += 3;
719            if i < tokens.len() && tokens[i] == Tok::Comma {
720                i += 1;
721            }
722            continue;
723        }
724        i += 1;
725    }
726
727    if name.is_empty() {
728        return None;
729    }
730
731    Some(RawDep {
732        namespace: String::new(),
733        name,
734        version,
735        scope: String::new(),
736        catalog_alias: None,
737        project_path: None,
738    })
739}
740
741fn parse_named_params(scope: &str, tokens: &[Tok]) -> Option<(RawDep, usize)> {
742    let mut group = String::new();
743    let mut name = String::new();
744    let mut version = String::new();
745    let mut i = 0;
746
747    while i < tokens.len() {
748        if let Tok::Ident(ref label) = tokens[i]
749            && i + 2 < tokens.len()
750            && tokens[i + 1] == Tok::Colon
751            && let Tok::Str(ref val) = tokens[i + 2]
752        {
753            match label.as_str() {
754                "group" => group = truncate_field(val.clone()),
755                "name" => name = truncate_field(val.clone()),
756                "version" => version = truncate_field(val.clone()),
757                _ => {}
758            }
759            i += 3;
760            if i < tokens.len() && tokens[i] == Tok::Comma {
761                i += 1;
762            }
763            continue;
764        }
765        break;
766    }
767
768    if name.is_empty() {
769        return None;
770    }
771
772    Some((
773        RawDep {
774            namespace: group,
775            name,
776            version,
777            scope: scope.to_string(),
778            catalog_alias: None,
779            project_path: None,
780        },
781        i,
782    ))
783}
784
785fn parse_project_ref(tokens: &[Tok]) -> Option<RawDep> {
786    if let Some(Tok::Str(val)) = tokens.first() {
787        let module_name = val.trim_start_matches(':');
788        let mut segments = module_name
789            .split(':')
790            .filter(|segment| !segment.is_empty())
791            .collect::<Vec<_>>();
792        let name = segments.pop().unwrap_or(module_name);
793        if name.is_empty() {
794            return None;
795        }
796        return Some(RawDep {
797            namespace: if segments.is_empty() {
798                String::new()
799            } else {
800                truncate_field(segments.join("/"))
801            },
802            name: truncate_field(name.to_string()),
803            version: String::new(),
804            scope: "project".to_string(),
805            catalog_alias: None,
806            project_path: Some(truncate_field(module_name.to_string())),
807        });
808    }
809    None
810}
811
812fn parse_colon_string(val: &str, scope: &str) -> RawDep {
813    let parts: Vec<&str> = val.split(':').collect();
814    let (namespace, name, version) = match parts.len() {
815        n if n >= 4 => (
816            truncate_field(parts[0].to_string()),
817            truncate_field(parts[1].to_string()),
818            truncate_field(parts[2].to_string()),
819        ),
820        3 => (
821            truncate_field(parts[0].to_string()),
822            truncate_field(parts[1].to_string()),
823            truncate_field(parts[2].to_string()),
824        ),
825        2 => (
826            truncate_field(parts[0].to_string()),
827            truncate_field(parts[1].to_string()),
828            String::new(),
829        ),
830        _ => (
831            String::new(),
832            truncate_field(val.to_string()),
833            String::new(),
834        ),
835    };
836
837    RawDep {
838        namespace,
839        name,
840        version,
841        scope: truncate_field(scope.to_string()),
842        catalog_alias: None,
843        project_path: None,
844    }
845}
846
847fn find_matching_paren(tokens: &[Tok], start: usize) -> Option<usize> {
848    if tokens.get(start) != Some(&Tok::OpenParen) {
849        return None;
850    }
851    let mut depth = 1;
852    let mut i = start + 1;
853    while i < tokens.len() && depth > 0 {
854        match &tokens[i] {
855            Tok::OpenParen => {
856                depth += 1;
857                if depth > MAX_RECURSION_DEPTH {
858                    warn!(
859                        "Gradle parser: nesting depth exceeded {} in find_matching_paren",
860                        MAX_RECURSION_DEPTH
861                    );
862                    break;
863                }
864            }
865            Tok::CloseParen => depth -= 1,
866            _ => {}
867        }
868        if depth == 0 {
869            return Some(i);
870        }
871        i += 1;
872    }
873    None
874}
875
876fn find_matching_bracket(tokens: &[Tok], start: usize) -> Option<usize> {
877    if tokens.get(start) != Some(&Tok::OpenBracket) {
878        return None;
879    }
880    let mut depth = 1;
881    let mut i = start + 1;
882    while i < tokens.len() && depth > 0 {
883        match &tokens[i] {
884            Tok::OpenBracket => {
885                depth += 1;
886                if depth > MAX_RECURSION_DEPTH {
887                    warn!(
888                        "Gradle parser: nesting depth exceeded {} in find_matching_bracket",
889                        MAX_RECURSION_DEPTH
890                    );
891                    break;
892                }
893            }
894            Tok::CloseBracket => depth -= 1,
895            _ => {}
896        }
897        if depth == 0 {
898            return Some(i);
899        }
900        i += 1;
901    }
902    None
903}
904
905// ---------------------------------------------------------------------------
906// Dependency construction
907// ---------------------------------------------------------------------------
908
909fn create_dependency(raw: &RawDep) -> Option<Dependency> {
910    let namespace = raw.namespace.as_str();
911    let name = raw.name.as_str();
912    let version = raw.version.as_str();
913    let scope = raw.scope.as_str();
914    if name.is_empty() {
915        return None;
916    }
917
918    let mut purl = PackageUrl::new("maven", name).ok()?;
919
920    if !namespace.is_empty() {
921        purl.with_namespace(namespace).ok()?;
922    }
923
924    if !version.is_empty() {
925        purl.with_version(version).ok()?;
926    }
927
928    let (is_runtime, is_optional) = classify_scope(scope);
929    let is_pinned = !version.is_empty();
930
931    let purl_string = truncate_field(purl.to_string().replace("$", "%24").replace('\'', "%27"));
932    let mut extra_data = std::collections::HashMap::new();
933    if let Some(alias) = &raw.catalog_alias {
934        extra_data.insert(
935            "catalog_alias".to_string(),
936            json!(truncate_field(alias.clone())),
937        );
938    }
939    if let Some(project_path) = &raw.project_path {
940        extra_data.insert(
941            "project_path".to_string(),
942            json!(truncate_field(project_path.clone())),
943        );
944    }
945
946    Some(Dependency {
947        purl: Some(purl_string),
948        extracted_requirement: Some(truncate_field(version.to_string())),
949        scope: Some(truncate_field(scope.to_string())),
950        is_runtime: Some(is_runtime),
951        is_optional: Some(is_optional),
952        is_pinned: Some(is_pinned),
953        is_direct: Some(true),
954        resolved_package: None,
955        extra_data: (!extra_data.is_empty()).then_some(extra_data),
956    })
957}
958
959fn classify_scope(scope: &str) -> (bool, bool) {
960    let scope_lower = scope.to_lowercase();
961
962    if scope_lower.contains("test") {
963        return (false, true);
964    }
965
966    if matches!(
967        scope_lower.as_str(),
968        "compileonly" | "compileonlyapi" | "annotationprocessor" | "kapt" | "ksp"
969    ) {
970        return (false, false);
971    }
972
973    (true, false)
974}
975
976#[derive(Debug, Clone)]
977struct GradleCatalogEntry {
978    namespace: String,
979    name: String,
980    version: Option<String>,
981}
982
983fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
984    let Some(catalog_path) = find_gradle_version_catalog(path) else {
985        return;
986    };
987    let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
988        return;
989    };
990
991    for dep in dependencies.iter_mut() {
992        let alias = dep
993            .extra_data
994            .as_ref()
995            .and_then(|data| data.get("catalog_alias"))
996            .and_then(|value| value.as_str());
997        let Some(alias) = alias else {
998            continue;
999        };
1000        let Some(entry) = entries.get(alias) else {
1001            continue;
1002        };
1003
1004        let mut purl = PackageUrl::new("maven", &entry.name).ok();
1005        if let Some(ref mut purl) = purl {
1006            if !entry.namespace.is_empty() {
1007                let _ = purl.with_namespace(&entry.namespace);
1008            }
1009            if let Some(version) = &entry.version {
1010                let _ = purl.with_version(version);
1011            }
1012        }
1013
1014        dep.purl = purl.map(|p| truncate_field(p.to_string()));
1015        dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1016        dep.is_pinned = Some(entry.version.is_some());
1017    }
1018}
1019
1020fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1021    for ancestor in path.ancestors() {
1022        let nested = ancestor.join("gradle").join("libs.versions.toml");
1023        if nested.is_file() {
1024            return Some(nested);
1025        }
1026
1027        let sibling = ancestor.join("libs.versions.toml");
1028        if sibling.is_file() {
1029            return Some(sibling);
1030        }
1031    }
1032
1033    None
1034}
1035
1036fn parse_gradle_version_catalog(
1037    path: &Path,
1038) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1039    let content = read_file_to_string(path, None).ok()?;
1040    let mut section = "";
1041    let mut versions = std::collections::HashMap::new();
1042    let mut libraries = std::collections::HashMap::new();
1043
1044    for line in content.lines().take(MAX_ITERATION_COUNT) {
1045        let trimmed = line.split('#').next().unwrap_or("").trim();
1046        if trimmed.is_empty() {
1047            continue;
1048        }
1049
1050        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1051            section = trimmed.trim_matches(&['[', ']'][..]);
1052            continue;
1053        }
1054
1055        let Some((key, value)) = trimmed.split_once('=') else {
1056            continue;
1057        };
1058        let key = key.trim().to_string();
1059        let value = value.trim().to_string();
1060
1061        match section {
1062            "versions" => {
1063                versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1064            }
1065            "libraries" => {
1066                libraries.insert(key, value);
1067            }
1068            _ => {}
1069        }
1070    }
1071
1072    let mut result = std::collections::HashMap::new();
1073    for (alias, raw_value) in libraries.into_iter().take(MAX_ITERATION_COUNT) {
1074        let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1075            continue;
1076        };
1077        result.insert(truncate_field(alias.replace('-', ".")), entry);
1078    }
1079
1080    Some(result)
1081}
1082
1083fn parse_gradle_catalog_entry(
1084    raw_value: &str,
1085    versions: &std::collections::HashMap<String, String>,
1086) -> Option<GradleCatalogEntry> {
1087    if raw_value.starts_with('"') && raw_value.ends_with('"') {
1088        let notation = strip_quotes(raw_value);
1089        let mut parts = notation.split(':');
1090        let namespace = truncate_field(parts.next()?.to_string());
1091        let name = truncate_field(parts.next()?.to_string());
1092        let version = parts.next().map(|v| truncate_field(v.to_string()));
1093        return Some(GradleCatalogEntry {
1094            namespace,
1095            name,
1096            version,
1097        });
1098    }
1099
1100    if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1101        return None;
1102    }
1103
1104    let inner = &raw_value[1..raw_value.len() - 1];
1105    let mut fields = std::collections::HashMap::new();
1106    for pair in inner.split(',').take(MAX_ITERATION_COUNT) {
1107        let Some((key, value)) = pair.split_once('=') else {
1108            continue;
1109        };
1110        fields.insert(
1111            truncate_field(key.trim().to_string()),
1112            truncate_field(strip_quotes(value.trim()).to_string()),
1113        );
1114    }
1115
1116    let (namespace, name) = if let Some(module) = fields.get("module") {
1117        let (group, artifact) = module.split_once(':')?;
1118        (
1119            truncate_field(group.to_string()),
1120            truncate_field(artifact.to_string()),
1121        )
1122    } else {
1123        (
1124            truncate_field(fields.get("group")?.to_string()),
1125            truncate_field(fields.get("name")?.to_string()),
1126        )
1127    };
1128
1129    let version = if let Some(version) = fields.get("version") {
1130        Some(truncate_field(version.to_string()))
1131    } else if let Some(version_ref) = fields.get("version.ref") {
1132        versions.get(version_ref).cloned().map(truncate_field)
1133    } else {
1134        None
1135    };
1136
1137    Some(GradleCatalogEntry {
1138        namespace,
1139        name,
1140        version,
1141    })
1142}
1143
1144fn strip_quotes(value: &str) -> &str {
1145    value
1146        .strip_prefix('"')
1147        .and_then(|v| v.strip_suffix('"'))
1148        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1149        .unwrap_or(value)
1150}
1151
1152fn extract_gradle_license_metadata(
1153    tokens: &[Tok],
1154) -> (
1155    Option<String>,
1156    Option<String>,
1157    Option<String>,
1158    Vec<crate::models::LicenseDetection>,
1159) {
1160    let mut i = 0;
1161    while i < tokens.len() {
1162        if let Tok::Ident(name) = &tokens[i]
1163            && name == "licenses"
1164            && i + 1 < tokens.len()
1165            && tokens[i + 1] == Tok::OpenBrace
1166            && let Some(block_end) = find_matching_brace(tokens, i + 1)
1167        {
1168            let inner = &tokens[i + 2..block_end];
1169            if let Some((license_name, license_url)) = parse_license_block(inner) {
1170                let extracted =
1171                    format_gradle_license_statement(&license_name, license_url.as_deref());
1172                let declared_candidate =
1173                    derive_gradle_license_expression(&license_name, license_url.as_deref());
1174                if let Some(declared_candidate) = declared_candidate
1175                    && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1176                {
1177                    let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1178                    let (declared, declared_spdx, detections) = build_declared_license_data(
1179                        normalized,
1180                        DeclaredLicenseMatchMetadata::single_line(matched_text),
1181                    );
1182                    return (
1183                        extracted.map(truncate_field),
1184                        declared.map(truncate_field),
1185                        declared_spdx.map(truncate_field),
1186                        detections,
1187                    );
1188                }
1189
1190                return (
1191                    extracted.map(truncate_field),
1192                    None,
1193                    None,
1194                    empty_declared_license_data().2,
1195                );
1196            }
1197            i = block_end + 1;
1198            continue;
1199        }
1200        i += 1;
1201    }
1202
1203    (None, None, None, Vec::new())
1204}
1205
1206fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1207    let mut i = 0;
1208    while i < tokens.len() {
1209        if let Tok::Ident(name) = &tokens[i]
1210            && name == "license"
1211            && i + 1 < tokens.len()
1212            && tokens[i + 1] == Tok::OpenBrace
1213            && let Some(block_end) = find_matching_brace(tokens, i + 1)
1214        {
1215            let mut license_name = None;
1216            let mut license_url = None;
1217            let block = &tokens[i + 2..block_end];
1218            let mut j = 0;
1219            while j < block.len() {
1220                if let Tok::Ident(label) = &block[j] {
1221                    let normalized = label.strip_suffix(".set").unwrap_or(label);
1222                    if (normalized == "name" || normalized == "url")
1223                        && let Some(value) = next_string_literal(block, j + 1)
1224                    {
1225                        if normalized == "name" {
1226                            license_name = Some(value);
1227                        } else {
1228                            license_url = Some(value);
1229                        }
1230                    }
1231                }
1232                j += 1;
1233            }
1234
1235            return license_name.map(|name| (name, license_url));
1236        }
1237        i += 1;
1238    }
1239    None
1240}
1241
1242fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1243    for token in tokens.iter().skip(start) {
1244        match token {
1245            Tok::Str(value) => return Some(truncate_field(value.clone())),
1246            Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1247            Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1248            _ => break,
1249        }
1250    }
1251    None
1252}
1253
1254fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1255    if tokens.get(start) != Some(&Tok::OpenBrace) {
1256        return None;
1257    }
1258    let mut depth = 1;
1259    let mut i = start + 1;
1260    while i < tokens.len() && depth > 0 {
1261        match &tokens[i] {
1262            Tok::OpenBrace => {
1263                depth += 1;
1264                if depth > MAX_RECURSION_DEPTH {
1265                    warn!(
1266                        "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1267                        MAX_RECURSION_DEPTH
1268                    );
1269                    break;
1270                }
1271            }
1272            Tok::CloseBrace => depth -= 1,
1273            _ => {}
1274        }
1275        if depth == 0 {
1276            return Some(i);
1277        }
1278        i += 1;
1279    }
1280    None
1281}
1282
1283fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
1284    let mut output = format!("- license:\n    name: {name}\n");
1285    if let Some(url) = url {
1286        output.push_str(&format!("    url: {url}\n"));
1287    }
1288    Some(truncate_field(output))
1289}
1290
1291fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
1292    let trimmed = name.trim();
1293    let candidates = [trimmed, url.unwrap_or("")];
1294
1295    for candidate in candidates {
1296        let lower = candidate.to_ascii_lowercase();
1297        if trimmed == "Apache-2.0"
1298            || lower.contains("apache-2.0")
1299            || lower.contains("apache license, version 2.0")
1300            || lower.contains("apache.org/licenses/license-2.0")
1301        {
1302            return Some(truncate_field("Apache-2.0".to_string()));
1303        }
1304        if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
1305            return Some(truncate_field("MIT".to_string()));
1306        }
1307        if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
1308            return Some(truncate_field(trimmed.to_string()));
1309        }
1310    }
1311
1312    None
1313}
1314
1315crate::register_parser!(
1316    "Gradle build script",
1317    &["**/build.gradle", "**/build.gradle.kts"],
1318    "maven",
1319    "Java",
1320    Some("https://gradle.org/"),
1321);
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326    use tempfile::tempdir;
1327
1328    #[test]
1329    fn test_is_match() {
1330        assert!(GradleParser::is_match(Path::new("build.gradle")));
1331        assert!(GradleParser::is_match(Path::new("build.gradle.kts")));
1332        assert!(GradleParser::is_match(Path::new("project/build.gradle")));
1333        assert!(!GradleParser::is_match(Path::new("build.xml")));
1334        assert!(!GradleParser::is_match(Path::new("settings.gradle")));
1335    }
1336
1337    #[test]
1338    fn test_extract_simple_dependencies() {
1339        let content = r#"
1340dependencies {
1341    compile 'org.apache.commons:commons-text:1.1'
1342    testCompile 'junit:junit:4.12'
1343}
1344"#;
1345        let tokens = lex(content);
1346        let deps = extract_dependencies(&tokens);
1347        assert_eq!(deps.len(), 2);
1348
1349        let dep1 = &deps[0];
1350        assert_eq!(
1351            dep1.purl,
1352            Some("pkg:maven/org.apache.commons/commons-text@1.1".to_string())
1353        );
1354        assert_eq!(dep1.scope, Some("compile".to_string()));
1355        assert_eq!(dep1.is_runtime, Some(true));
1356        assert_eq!(dep1.is_pinned, Some(true));
1357
1358        let dep2 = &deps[1];
1359        assert_eq!(dep2.purl, Some("pkg:maven/junit/junit@4.12".to_string()));
1360        assert_eq!(dep2.scope, Some("testCompile".to_string()));
1361        assert_eq!(dep2.is_runtime, Some(false));
1362        assert_eq!(dep2.is_optional, Some(true));
1363    }
1364
1365    #[test]
1366    fn test_extract_parens_notation() {
1367        let content = r#"
1368dependencies {
1369    implementation("com.example:library:1.0.0")
1370    testImplementation("junit:junit:4.13")
1371}
1372"#;
1373        let tokens = lex(content);
1374        let deps = extract_dependencies(&tokens);
1375        assert_eq!(deps.len(), 2);
1376        assert_eq!(
1377            deps[0].purl,
1378            Some("pkg:maven/com.example/library@1.0.0".to_string())
1379        );
1380    }
1381
1382    #[test]
1383    fn test_extract_named_parameters() {
1384        let content = r#"
1385dependencies {
1386    api group: 'com.google.guava', name: 'guava', version: '30.1-jre'
1387}
1388"#;
1389        let tokens = lex(content);
1390        let deps = extract_dependencies(&tokens);
1391        assert_eq!(deps.len(), 1);
1392        assert_eq!(
1393            deps[0].purl,
1394            Some("pkg:maven/com.google.guava/guava@30.1-jre".to_string())
1395        );
1396        assert_eq!(deps[0].scope, Some("api".to_string()));
1397    }
1398
1399    #[test]
1400    fn test_multiple_dependency_blocks_all_parsed() {
1401        let content = r#"
1402dependencies {
1403    implementation 'org.scala-lang:scala-library:2.11.12'
1404}
1405
1406dependencies {
1407    implementation 'commons-collections:commons-collections:3.2.2'
1408    testImplementation 'junit:junit:4.13'
1409}
1410"#;
1411        let tokens = lex(content);
1412        let deps = extract_dependencies(&tokens);
1413        assert_eq!(deps.len(), 3);
1414        assert_eq!(
1415            deps[0].purl,
1416            Some("pkg:maven/org.scala-lang/scala-library@2.11.12".to_string())
1417        );
1418        assert_eq!(
1419            deps[1].purl,
1420            Some("pkg:maven/commons-collections/commons-collections@3.2.2".to_string())
1421        );
1422        assert_eq!(deps[2].purl, Some("pkg:maven/junit/junit@4.13".to_string()));
1423        assert_eq!(deps[2].scope, Some("testImplementation".to_string()));
1424    }
1425
1426    #[test]
1427    fn test_nested_dependency_blocks_all_parsed() {
1428        let content = r#"
1429buildscript {
1430    dependencies {
1431        classpath("org.eclipse.jgit:org.eclipse.jgit:$jgitVersion")
1432    }
1433}
1434
1435subprojects {
1436    dependencies {
1437        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinPluginVersion")
1438    }
1439}
1440"#;
1441        let tokens = lex(content);
1442        let deps = extract_dependencies(&tokens);
1443
1444        assert_eq!(deps.len(), 2);
1445        assert_eq!(
1446            deps[0].purl,
1447            Some("pkg:maven/org.eclipse.jgit/org.eclipse.jgit@%24jgitVersion".to_string())
1448        );
1449        assert_eq!(deps[0].scope, Some("classpath".to_string()));
1450        assert_eq!(
1451            deps[1].purl,
1452            Some(
1453                "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib-jdk8@%24kotlinPluginVersion"
1454                    .to_string()
1455            )
1456        );
1457        assert_eq!(deps[1].scope, Some("implementation".to_string()));
1458    }
1459
1460    #[test]
1461    fn test_no_version() {
1462        let content = r#"
1463dependencies {
1464    compile 'org.example:library'
1465}
1466"#;
1467        let tokens = lex(content);
1468        let deps = extract_dependencies(&tokens);
1469        assert_eq!(deps.len(), 1);
1470        assert_eq!(deps[0].is_pinned, Some(false));
1471        assert_eq!(deps[0].extracted_requirement, Some("".to_string()));
1472    }
1473
1474    #[test]
1475    fn test_nested_function_calls() {
1476        let content = r#"
1477dependencies {
1478    implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.12.2"))
1479    testImplementation(platform("org.junit:junit-bom:5.7.2"))
1480}
1481"#;
1482        let tokens = lex(content);
1483        let deps = extract_dependencies(&tokens);
1484        assert_eq!(deps.len(), 2);
1485        assert_eq!(
1486            deps[0].purl,
1487            Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2".to_string())
1488        );
1489        assert_eq!(deps[0].scope, Some("enforcedPlatform".to_string()));
1490        assert_eq!(deps[1].scope, Some("platform".to_string()));
1491    }
1492
1493    #[test]
1494    fn test_map_format() {
1495        let content = r#"
1496dependencies {
1497    runtimeOnly(
1498        [group: 'org.jacoco', name: 'org.jacoco.ant', version: '0.7.4.201502262128'],
1499        [group: 'org.jacoco', name: 'org.jacoco.agent', version: '0.7.4.201502262128']
1500    )
1501}
1502"#;
1503        let tokens = lex(content);
1504        let deps = extract_dependencies(&tokens);
1505        assert_eq!(deps.len(), 2);
1506        assert_eq!(deps[0].scope, Some("".to_string()));
1507        assert_eq!(
1508            deps[0].purl,
1509            Some("pkg:maven/org.jacoco.ant@0.7.4.201502262128".to_string())
1510        );
1511    }
1512
1513    #[test]
1514    fn test_bracket_map_dedupes_exact_string_overlap() {
1515        let content = r#"
1516dependencies {
1517    runtimeOnly 'org.springframework:spring-core:2.5',
1518            'org.springframework:spring-aop:2.5'
1519    runtimeOnly(
1520        [group: 'org.springframework', name: 'spring-core', version: '2.5'],
1521        [group: 'org.springframework', name: 'spring-aop', version: '2.5']
1522    )
1523}
1524"#;
1525
1526        let tokens = lex(content);
1527        let deps = extract_dependencies(&tokens);
1528        assert_eq!(deps.len(), 2);
1529        assert_eq!(
1530            deps[0].purl,
1531            Some("pkg:maven/org.springframework/spring-core@2.5".to_string())
1532        );
1533        assert_eq!(
1534            deps[1].purl,
1535            Some("pkg:maven/org.springframework/spring-aop@2.5".to_string())
1536        );
1537    }
1538
1539    #[test]
1540    fn test_malformed_string_stops_cascading_false_positives() {
1541        let content = r#"
1542dependencies {
1543    implementation "com.fasterxml.jackson:jackson-bom:2.12.2'
1544    implementation" com.fasterxml.jackson.core:jackson-core"
1545    testImplementation 'org.junit:junit-bom:5.7.2'"
1546    testImplementation "org.junit.platform:junit-platform-commons"
1547}
1548"#;
1549
1550        let tokens = lex(content);
1551        let deps = extract_dependencies(&tokens);
1552        assert_eq!(deps.len(), 1);
1553        assert_eq!(
1554            deps[0].purl,
1555            Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2%27".to_string())
1556        );
1557    }
1558
1559    #[test]
1560    fn test_project_references() {
1561        let content = r#"
1562dependencies {
1563    implementation(project(":documentation"))
1564    implementation(project(":basics"))
1565}
1566"#;
1567        let tokens = lex(content);
1568        let deps = extract_dependencies(&tokens);
1569        assert_eq!(deps.len(), 2);
1570        assert_eq!(deps[0].scope, Some("project".to_string()));
1571        assert_eq!(deps[0].purl, Some("pkg:maven/documentation".to_string()));
1572        assert_eq!(deps[1].purl, Some("pkg:maven/basics".to_string()));
1573    }
1574
1575    #[test]
1576    fn test_nested_project_references_preserve_parent_path() {
1577        let content = r#"
1578dependencies {
1579    implementation(project(":libs:download"))
1580    implementation(project(":libs:index"))
1581}
1582"#;
1583        let tokens = lex(content);
1584        let deps = extract_dependencies(&tokens);
1585
1586        assert_eq!(deps.len(), 2);
1587        assert_eq!(deps[0].purl, Some("pkg:maven/libs/download".to_string()));
1588        assert_eq!(deps[0].scope, Some("project".to_string()));
1589        assert_eq!(deps[1].purl, Some("pkg:maven/libs/index".to_string()));
1590    }
1591
1592    #[test]
1593    fn test_compile_only_is_not_runtime() {
1594        let content = r#"
1595dependencies {
1596    compileOnly 'org.antlr:antlr:2.7.7'
1597    compileOnlyApi 'com.example:annotations:1.0.0'
1598    testCompileOnly 'junit:junit:4.13'
1599}
1600"#;
1601        let tokens = lex(content);
1602        let deps = extract_dependencies(&tokens);
1603
1604        assert_eq!(deps.len(), 3);
1605        assert_eq!(deps[0].scope, Some("compileOnly".to_string()));
1606        assert_eq!(deps[0].is_runtime, Some(false));
1607        assert_eq!(deps[0].is_optional, Some(false));
1608
1609        assert_eq!(deps[1].scope, Some("compileOnlyApi".to_string()));
1610        assert_eq!(deps[1].is_runtime, Some(false));
1611        assert_eq!(deps[1].is_optional, Some(false));
1612
1613        assert_eq!(deps[2].scope, Some("testCompileOnly".to_string()));
1614        assert_eq!(deps[2].is_runtime, Some(false));
1615        assert_eq!(deps[2].is_optional, Some(true));
1616    }
1617
1618    #[test]
1619    fn test_version_catalog_alias_resolution_from_libs_versions_toml() {
1620        let temp_dir = tempdir().unwrap();
1621        let gradle_dir = temp_dir.path().join("gradle");
1622        std::fs::create_dir_all(&gradle_dir).unwrap();
1623
1624        std::fs::write(
1625            gradle_dir.join("libs.versions.toml"),
1626            r#"
1627[versions]
1628androidxAppcompat = "1.7.0"
1629
1630[libraries]
1631androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
1632guardianproject-panic = { group = "info.guardianproject", name = "panic", version = "1.0.0" }
1633"#,
1634        )
1635        .unwrap();
1636
1637        let build_gradle = temp_dir.path().join("build.gradle");
1638        std::fs::write(
1639            &build_gradle,
1640            r#"
1641dependencies {
1642    implementation libs.androidx.appcompat
1643    fullImplementation libs.guardianproject.panic
1644}
1645"#,
1646        )
1647        .unwrap();
1648
1649        let package_data = GradleParser::extract_first_package(&build_gradle);
1650
1651        assert_eq!(package_data.dependencies.len(), 2);
1652        assert_eq!(
1653            package_data.dependencies[0].purl,
1654            Some("pkg:maven/androidx.appcompat/appcompat@1.7.0".to_string())
1655        );
1656        assert_eq!(
1657            package_data.dependencies[0].scope,
1658            Some("implementation".to_string())
1659        );
1660        assert_eq!(
1661            package_data.dependencies[1].purl,
1662            Some("pkg:maven/info.guardianproject/panic@1.0.0".to_string())
1663        );
1664        assert_eq!(
1665            package_data.dependencies[1].scope,
1666            Some("fullImplementation".to_string())
1667        );
1668    }
1669
1670    #[test]
1671    fn test_extract_gradle_license_metadata_from_pom_block() {
1672        let content = r#"
1673plugins {
1674    id 'java-library'
1675    id 'maven'
1676}
1677
1678dependencies {
1679    api 'org.apache.commons:commons-text:1.1'
1680}
1681
1682configure(install.repositories.mavenInstaller) {
1683    pom.project {
1684        licenses {
1685            license {
1686                name 'The Apache License, Version 2.0'
1687                url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
1688            }
1689        }
1690    }
1691}
1692"#;
1693
1694        let temp_dir = tempdir().unwrap();
1695        let build_gradle = temp_dir.path().join("build.gradle");
1696        std::fs::write(&build_gradle, content).unwrap();
1697
1698        let package_data = GradleParser::extract_first_package(&build_gradle);
1699
1700        assert_eq!(
1701            package_data.extracted_license_statement,
1702            Some(
1703                "- license:\n    name: The Apache License, Version 2.0\n    url: http://www.apache.org/licenses/LICENSE-2.0.txt\n"
1704                    .to_string()
1705            )
1706        );
1707        assert_eq!(
1708            package_data.declared_license_expression_spdx,
1709            Some("Apache-2.0".to_string())
1710        );
1711    }
1712
1713    #[test]
1714    fn test_parse_gradle_version_catalog_helper() {
1715        let temp_dir = tempdir().unwrap();
1716        let catalog_path = temp_dir.path().join("libs.versions.toml");
1717        std::fs::write(
1718            &catalog_path,
1719            r#"
1720[versions]
1721androidxAppcompat = "1.7.0"
1722
1723[libraries]
1724androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
1725"#,
1726        )
1727        .unwrap();
1728
1729        let entries = parse_gradle_version_catalog(&catalog_path).unwrap();
1730        let entry = entries.get("androidx.appcompat").unwrap();
1731
1732        assert_eq!(entry.namespace, "androidx.appcompat");
1733        assert_eq!(entry.name, "appcompat");
1734        assert_eq!(entry.version.as_deref(), Some("1.7.0"));
1735    }
1736
1737    #[test]
1738    fn test_string_interpolation() {
1739        let content = r#"
1740dependencies {
1741    compile "com.amazonaws:aws-java-sdk-core:${awsVer}"
1742}
1743"#;
1744        let tokens = lex(content);
1745        let deps = extract_dependencies(&tokens);
1746        assert_eq!(deps.len(), 1);
1747        assert_eq!(deps[0].extracted_requirement, Some("${awsVer}".to_string()));
1748        assert_eq!(
1749            deps[0].purl,
1750            Some("pkg:maven/com.amazonaws/aws-java-sdk-core@%24%7BawsVer%7D".to_string())
1751        );
1752    }
1753
1754    #[test]
1755    fn test_multi_value_string_notation() {
1756        let content = r#"
1757dependencies {
1758    runtimeOnly 'org.springframework:spring-core:2.5',
1759            'org.springframework:spring-aop:2.5'
1760}
1761"#;
1762        let tokens = lex(content);
1763        let deps = extract_dependencies(&tokens);
1764        assert_eq!(deps.len(), 2);
1765        assert_eq!(deps[0].scope, Some("".to_string()));
1766        assert_eq!(deps[1].scope, Some("".to_string()));
1767    }
1768
1769    #[test]
1770    fn test_kotlin_quoted_scope_not_extracted() {
1771        let content = r#"
1772dependencies {
1773    "js"("jquery:jquery:3.2.1@js")
1774}
1775"#;
1776        let tokens = lex(content);
1777        let deps = extract_dependencies(&tokens);
1778        assert_eq!(deps.len(), 0);
1779    }
1780
1781    #[test]
1782    fn test_kotlin_quoted_scope_project_reference_extracted() {
1783        let content = r#"
1784subprojects {
1785    dependencies {
1786        "testImplementation"(project(":utils:test-utils"))
1787    }
1788}
1789"#;
1790        let tokens = lex(content);
1791        let deps = extract_dependencies(&tokens);
1792        assert_eq!(deps.len(), 1);
1793        assert_eq!(deps[0].scope, Some("project".to_string()));
1794        assert_eq!(deps[0].purl, Some("pkg:maven/utils/test-utils".to_string()));
1795    }
1796
1797    #[test]
1798    fn test_closure_after_dependency() {
1799        let content = r#"
1800dependencies {
1801    runtimeOnly('org.hibernate:hibernate:3.0.5') {
1802        transitive = true
1803    }
1804}
1805"#;
1806        let tokens = lex(content);
1807        let deps = extract_dependencies(&tokens);
1808        assert_eq!(deps.len(), 1);
1809        assert_eq!(
1810            deps[0].purl,
1811            Some("pkg:maven/org.hibernate/hibernate@3.0.5".to_string())
1812        );
1813        assert_eq!(deps[0].scope, Some("runtimeOnly".to_string()));
1814    }
1815}