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