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