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