Skip to main content

provenant/parsers/
gradle.rs

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