Skip to main content

provenant/parsers/
gradle.rs

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