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