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