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