Skip to main content

provenant/parsers/
gradle.rs

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