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