1use std::collections::{HashMap, HashSet};
28use std::path::{Path, PathBuf};
29use std::sync::{Mutex, OnceLock};
30
31use crate::parser_warn as warn;
32use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
33
34const MAX_RECURSION_DEPTH: usize = 50;
35use packageurl::PackageUrl;
36use serde_json::json;
37
38use super::metadata::ParserMetadata;
39use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
40use crate::parsers::PackageParser;
41
42use super::license_normalization::{
43 DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
44 normalize_spdx_expression,
45};
46
47pub struct GradleParser;
71
72impl PackageParser for GradleParser {
73 const PACKAGE_TYPE: PackageType = PackageType::Maven;
74
75 fn metadata() -> Vec<ParserMetadata> {
76 vec![ParserMetadata {
77 description: "Gradle build script",
78 file_patterns: &["**/build.gradle", "**/build.gradle.kts"],
79 package_type: "maven",
80 primary_language: "Java",
81 documentation_url: Some("https://gradle.org/"),
82 }]
83 }
84
85 fn is_match(path: &Path) -> bool {
86 path.file_name().is_some_and(|name| {
87 let name_str = name.to_string_lossy();
88 name_str == "build.gradle" || name_str == "build.gradle.kts"
89 })
90 }
91
92 fn extract_packages(path: &Path) -> Vec<PackageData> {
93 let content = match read_file_to_string(path, None) {
94 Ok(c) => c,
95 Err(e) => {
96 warn!("Failed to read {:?}: {}", path, e);
97 return vec![default_package_data()];
98 }
99 };
100
101 let tokens = lex(&content);
102 let dependencies = extract_dependencies_with_context(path, &content, &tokens);
103 let (
104 extracted_license_statement,
105 declared_license_expression,
106 declared_license_expression_spdx,
107 license_detections,
108 ) = extract_gradle_license_metadata(&tokens);
109
110 vec![PackageData {
111 package_type: Some(Self::PACKAGE_TYPE),
112 namespace: None,
113 name: None,
114 version: None,
115 qualifiers: None,
116 subpath: None,
117 primary_language: None,
118 description: None,
119 release_date: None,
120 parties: Vec::new(),
121 keywords: Vec::new(),
122 homepage_url: None,
123 download_url: None,
124 size: None,
125 sha1: None,
126 md5: None,
127 sha256: None,
128 sha512: None,
129 bug_tracking_url: None,
130 code_view_url: None,
131 vcs_url: None,
132 copyright: None,
133 holder: None,
134 declared_license_expression,
135 declared_license_expression_spdx,
136 license_detections,
137 other_license_expression: None,
138 other_license_expression_spdx: None,
139 other_license_detections: Vec::new(),
140 extracted_license_statement,
141 notice_text: None,
142 source_packages: Vec::new(),
143 file_references: Vec::new(),
144 extra_data: None,
145 dependencies,
146 repository_homepage_url: None,
147 repository_download_url: None,
148 api_data_url: None,
149 datasource_id: Some(DatasourceId::BuildGradle),
150 purl: None,
151 is_private: false,
152 is_virtual: false,
153 }]
154 }
155}
156
157fn default_package_data() -> PackageData {
158 PackageData {
159 package_type: Some(GradleParser::PACKAGE_TYPE),
160 datasource_id: Some(DatasourceId::BuildGradle),
161 ..Default::default()
162 }
163}
164
165#[derive(Debug, Clone, PartialEq)]
170enum Tok {
171 Ident(String),
172 Str(String),
173 MalformedStr(String),
174 OpenParen,
175 CloseParen,
176 OpenBracket,
177 CloseBracket,
178 OpenBrace,
179 CloseBrace,
180 Colon,
181 Comma,
182 Equals,
183}
184
185fn lex(input: &str) -> Vec<Tok> {
186 let chars: Vec<char> = input.chars().collect();
187 let len = chars.len();
188 let mut i = 0;
189 let mut tokens = Vec::new();
190
191 while i < len {
192 if tokens.len() >= MAX_ITERATION_COUNT {
193 warn!(
194 "Lexer exceeded MAX_ITERATION_COUNT ({}) tokens, stopping",
195 MAX_ITERATION_COUNT
196 );
197 break;
198 }
199 let c = chars[i];
200
201 if c == '/' && i + 1 < len && chars[i + 1] == '/' {
202 while i < len && chars[i] != '\n' {
203 i += 1;
204 }
205 continue;
206 }
207
208 if c == '/' && i + 1 < len && chars[i + 1] == '*' {
209 i += 2;
210 while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
211 i += 1;
212 }
213 i += 2;
214 continue;
215 }
216
217 if c.is_whitespace() {
218 i += 1;
219 continue;
220 }
221
222 if c == '\'' {
223 i += 1;
224 let start = i;
225 while i < len && chars[i] != '\'' && chars[i] != '\n' {
226 i += 1;
227 }
228 let val: String = chars[start..i].iter().collect();
229 let val = truncate_field(val);
230 if i < len && chars[i] == '\'' {
231 tokens.push(Tok::Str(val));
232 i += 1;
233 } else {
234 tokens.push(Tok::MalformedStr(val));
235 }
236 continue;
237 }
238
239 if c == '"' {
240 i += 1;
241 let start = i;
242 while i < len && chars[i] != '"' && chars[i] != '\n' {
243 if chars[i] == '\\' && i + 1 < len {
244 i += 2;
245 } else {
246 i += 1;
247 }
248 }
249 let val: String = chars[start..i].iter().collect();
250 let val = truncate_field(val);
251 if i < len && chars[i] == '"' {
252 tokens.push(Tok::Str(val));
253 i += 1;
254 } else {
255 tokens.push(Tok::MalformedStr(val));
256 }
257 continue;
258 }
259
260 match c {
261 '(' => {
262 tokens.push(Tok::OpenParen);
263 i += 1;
264 }
265 ')' => {
266 tokens.push(Tok::CloseParen);
267 i += 1;
268 }
269 '[' => {
270 tokens.push(Tok::OpenBracket);
271 i += 1;
272 }
273 ']' => {
274 tokens.push(Tok::CloseBracket);
275 i += 1;
276 }
277 '{' => {
278 tokens.push(Tok::OpenBrace);
279 i += 1;
280 }
281 '}' => {
282 tokens.push(Tok::CloseBrace);
283 i += 1;
284 }
285 ':' => {
286 tokens.push(Tok::Colon);
287 i += 1;
288 }
289 ',' => {
290 tokens.push(Tok::Comma);
291 i += 1;
292 }
293 '=' => {
294 tokens.push(Tok::Equals);
295 i += 1;
296 }
297 _ if is_ident_start(c) => {
298 let start = i;
299 while i < len && is_ident_char(chars[i]) {
300 i += 1;
301 }
302 let val: String = chars[start..i].iter().collect();
303 tokens.push(Tok::Ident(truncate_field(val)));
304 }
305 _ => {
306 i += 1;
307 }
308 }
309 }
310
311 tokens
312}
313
314fn is_ident_start(c: char) -> bool {
315 c.is_ascii_alphanumeric() || c == '_' || c == '-'
316}
317
318fn is_ident_char(c: char) -> bool {
319 c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '$'
320}
321
322fn find_dependency_blocks(tokens: &[Tok]) -> Vec<Vec<Tok>> {
327 let mut blocks = Vec::new();
328 let mut i = 0;
329
330 while i < tokens.len() {
331 if let Tok::Ident(ref name) = tokens[i]
332 && name == "dependencies"
333 && i + 1 < tokens.len()
334 && tokens[i + 1] == Tok::OpenBrace
335 {
336 i += 2;
337 let mut depth = 1;
338 let start = i;
339 while i < tokens.len() && depth > 0 {
340 match &tokens[i] {
341 Tok::OpenBrace => {
342 depth += 1;
343 if depth > MAX_RECURSION_DEPTH {
344 warn!(
345 "Gradle parser: nesting depth exceeded {} in find_dependency_blocks",
346 MAX_RECURSION_DEPTH
347 );
348 break;
349 }
350 }
351 Tok::CloseBrace => depth -= 1,
352 _ => {}
353 }
354 if depth > 0 {
355 i += 1;
356 }
357 }
358 blocks.push(tokens[start..i].to_vec());
359 if i < tokens.len() {
360 i += 1;
361 }
362 continue;
363 }
364 i += 1;
365 }
366
367 blocks
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Hash)]
375struct RawDep {
376 namespace: String,
377 name: String,
378 version: String,
379 scope: String,
380 catalog_alias: Option<String>,
381 symbolic_ref: Option<String>,
382 project_path: Option<String>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
386enum BuildSrcExpr {
387 Literal(String),
388 Ref(String),
389}
390
391#[derive(Debug, Clone, PartialEq, Eq)]
392struct BuildSrcConst {
393 scope: String,
394 expr: BuildSrcExpr,
395}
396
397type BuildSrcConstMap = HashMap<String, BuildSrcConst>;
398type BuildSrcCache = HashMap<PathBuf, Option<BuildSrcConstMap>>;
399
400static BUILD_SRC_CONSTANT_CACHE: OnceLock<Mutex<BuildSrcCache>> = OnceLock::new();
401
402fn extract_dependencies_with_context(
403 path: &Path,
404 content: &str,
405 tokens: &[Tok],
406) -> Vec<Dependency> {
407 let mut raw_dependencies = extract_raw_dependencies(tokens);
408 resolve_gradle_script_interpolations(path, content, &mut raw_dependencies);
409 resolve_gradle_buildsrc_symbolic_refs(path, &mut raw_dependencies);
410 let mut dependencies = raw_dependencies
411 .iter()
412 .filter_map(create_dependency)
413 .collect::<Vec<_>>();
414 resolve_gradle_version_catalog_aliases(path, &mut dependencies);
415 dependencies
416}
417
418#[cfg(test)]
419fn extract_dependencies(tokens: &[Tok]) -> Vec<Dependency> {
420 extract_raw_dependencies(tokens)
421 .iter()
422 .filter_map(create_dependency)
423 .collect()
424}
425
426fn extract_raw_dependencies(tokens: &[Tok]) -> Vec<RawDep> {
427 let blocks = find_dependency_blocks(tokens);
428 let mut dependencies = Vec::new();
429
430 for block in blocks {
431 for rd in parse_block(&block).into_iter().take(MAX_ITERATION_COUNT) {
432 dependencies.push(rd);
433 }
434 }
435
436 dependencies
437}
438
439fn parse_block(tokens: &[Tok]) -> Vec<RawDep> {
440 let mut deps = Vec::new();
441 let mut i = 0;
442 let mut iterations = 0;
443
444 while i < tokens.len() {
445 iterations += 1;
446 if iterations > MAX_ITERATION_COUNT {
447 warn!(
448 "parse_block exceeded MAX_ITERATION_COUNT ({}) iterations, stopping",
449 MAX_ITERATION_COUNT
450 );
451 break;
452 }
453
454 if let Some(next_index) = parse_control_flow_block(tokens, i, &mut deps) {
455 i = next_index;
456 continue;
457 }
458
459 if tokens[i] == Tok::OpenBrace {
461 let mut depth = 1;
462 i += 1;
463 while i < tokens.len() && depth > 0 {
464 match &tokens[i] {
465 Tok::OpenBrace => {
466 depth += 1;
467 if depth > MAX_RECURSION_DEPTH {
468 warn!(
469 "Gradle parser: nesting depth exceeded {} in parse_block",
470 MAX_RECURSION_DEPTH
471 );
472 break;
473 }
474 }
475 Tok::CloseBrace => depth -= 1,
476 _ => {}
477 }
478 i += 1;
479 }
480 continue;
481 }
482
483 if let Tok::Str(scope_name) = &tokens[i]
484 && i + 1 < tokens.len()
485 && tokens[i + 1] == Tok::OpenParen
486 && let Some(end) = find_matching_paren(tokens, i + 1)
487 {
488 let inner = &tokens[i + 2..end];
489 parse_paren_content(scope_name, inner, &mut deps);
490 i = end + 1;
491 continue;
492 }
493
494 let scope_name = match &tokens[i] {
495 Tok::Ident(name) => name.clone(),
496 _ => {
497 i += 1;
498 continue;
499 }
500 };
501
502 if is_skip_keyword(&scope_name) {
503 i += 1;
504 continue;
505 }
506
507 let next = i + 1;
508
509 if next < tokens.len() && tokens[next] == Tok::OpenParen {
511 let paren_end = find_matching_paren(tokens, next);
512 if let Some(end) = paren_end {
513 let inner = &tokens[next + 1..end];
514 parse_paren_content(&scope_name, inner, &mut deps);
515 i = end + 1;
516 continue;
517 }
518 }
519
520 if next < tokens.len()
522 && let Tok::Ident(ref label) = tokens[next]
523 && label == "group"
524 && next + 1 < tokens.len()
525 && tokens[next + 1] == Tok::Colon
526 && let Some((rd, consumed)) = parse_named_params(&scope_name, &tokens[next..])
527 {
528 deps.push(rd);
529 i = next + consumed;
530 continue;
531 }
532
533 if next < tokens.len()
535 && matches!(
536 tokens.get(next),
537 Some(Tok::Str(_)) | Some(Tok::MalformedStr(_))
538 )
539 {
540 let (val, is_malformed) = match &tokens[next] {
541 Tok::Str(val) => (val.as_str(), false),
542 Tok::MalformedStr(val) => (val.as_str(), true),
543 _ => unreachable!(),
544 };
545
546 if !val.contains(':') {
547 i = next + 1;
548 continue;
549 }
550
551 if val.chars().next().is_some_and(|c| c.is_whitespace()) {
552 break;
553 }
554
555 if next + 1 < tokens.len()
557 && tokens[next + 1] == Tok::Comma
558 && next + 2 < tokens.len()
559 && tokens[next + 2] == Tok::OpenBrace
560 {
561 i = next + 1;
562 continue;
563 }
564 let is_multi = i + 2 < tokens.len()
565 && tokens[next + 1] == Tok::Comma
566 && matches!(tokens.get(next + 2), Some(Tok::Str(_)));
567 let effective_scope = if is_multi { "" } else { &scope_name };
568 let rd = parse_colon_string(val, effective_scope);
569 deps.push(rd);
570 if is_malformed {
571 break;
572 }
573 i = next + 1;
574 while i < tokens.len() && tokens[i] == Tok::Comma {
575 i += 1;
576 if i < tokens.len()
577 && let Tok::Str(ref v2) = tokens[i]
578 && v2.contains(':')
579 {
580 deps.push(parse_colon_string(v2, ""));
581 i += 1;
582 continue;
583 }
584 break;
585 }
586 continue;
587 }
588
589 if next < tokens.len()
594 && let Tok::Ident(ref val) = tokens[next]
595 && val.starts_with("libs.")
596 && let Some(last_seg) = val.rsplit('.').next()
597 && !last_seg.is_empty()
598 {
599 deps.push(RawDep {
600 namespace: String::new(),
601 name: truncate_field(last_seg.to_string()),
602 version: String::new(),
603 scope: truncate_field(scope_name.clone()),
604 catalog_alias: val
605 .strip_prefix("libs.")
606 .map(|alias| truncate_field(alias.to_string())),
607 symbolic_ref: None,
608 project_path: None,
609 });
610 i = next + 1;
611 continue;
612 }
613
614 if next < tokens.len()
615 && let Tok::Ident(ref val) = tokens[next]
616 && val.contains('.')
617 {
618 deps.push(parse_symbolic_ref(&scope_name, val));
619 i = next + 1;
620 continue;
621 }
622
623 if next < tokens.len()
625 && let Tok::Ident(ref name) = tokens[next]
626 && name == "project"
627 && next + 1 < tokens.len()
628 && tokens[next + 1] == Tok::OpenParen
629 && let Some(end) = find_matching_paren(tokens, next + 1)
630 {
631 let inner = &tokens[next + 2..end];
632 if let Some(rd) = parse_project_ref(inner, &scope_name) {
633 deps.push(rd);
634 }
635 i = end + 1;
636 continue;
637 }
638
639 i += 1;
640 }
641
642 deps
643}
644
645fn parse_control_flow_block(tokens: &[Tok], start: usize, deps: &mut Vec<RawDep>) -> Option<usize> {
646 let Tok::Ident(keyword) = tokens.get(start)? else {
647 return None;
648 };
649
650 if keyword != "if" && keyword != "else" {
651 return None;
652 }
653
654 let mut block_start = start + 1;
655 if keyword == "if" {
656 if tokens.get(block_start) != Some(&Tok::OpenParen) {
657 return None;
658 }
659 let cond_end = find_matching_paren(tokens, block_start)?;
660 block_start = cond_end + 1;
661 } else if let Some(Tok::Ident(next)) = tokens.get(block_start)
662 && next == "if"
663 {
664 return parse_control_flow_block(tokens, block_start, deps);
665 }
666
667 if tokens.get(block_start) != Some(&Tok::OpenBrace) {
668 return None;
669 }
670
671 let block_end = find_matching_brace(tokens, block_start)?;
672 deps.extend(parse_block(&tokens[block_start + 1..block_end]));
673 Some(block_end + 1)
674}
675
676fn is_skip_keyword(name: &str) -> bool {
677 matches!(
678 name,
679 "plugins"
680 | "apply"
681 | "ext"
682 | "configurations"
683 | "repositories"
684 | "subprojects"
685 | "allprojects"
686 | "buildscript"
687 | "pluginManager"
688 | "publishing"
689 | "sourceSets"
690 | "tasks"
691 | "task"
692 )
693}
694
695fn parse_paren_content(scope: &str, tokens: &[Tok], deps: &mut Vec<RawDep>) {
696 if tokens.is_empty() {
697 return;
698 }
699
700 if tokens[0] == Tok::OpenBracket {
702 parse_bracket_maps(tokens, deps);
703 return;
704 }
705
706 if let Some(Tok::Ident(label)) = tokens.first()
708 && label == "group"
709 && tokens.len() > 1
710 && tokens[1] == Tok::Colon
711 {
712 if let Some((rd, _)) = parse_named_params("", tokens) {
713 deps.push(rd);
714 }
715 return;
716 }
717
718 if let Some(Tok::Ident(inner_fn)) = tokens.first()
720 && tokens.len() > 1
721 && tokens[1] == Tok::OpenParen
722 {
723 if inner_fn == "project" {
724 if let Some(end) = find_matching_paren(tokens, 1) {
725 let inner = &tokens[2..end];
726 if let Some(rd) = parse_project_ref(inner, scope) {
727 deps.push(rd);
728 }
729 }
730 return;
731 }
732
733 if let Some(end) = find_matching_paren(tokens, 1) {
734 let inner = &tokens[2..end];
735 if let Some(Tok::Str(val)) = inner.first()
736 && val.contains(':')
737 {
738 deps.push(parse_colon_string(val, inner_fn));
739 return;
740 }
741
742 if let Some(Tok::Ident(val)) = inner.first()
743 && val.contains('.')
744 {
745 deps.push(parse_symbolic_ref(inner_fn, val));
746 return;
747 }
748 }
749 }
750
751 if let Some(Tok::Ident(val)) = tokens.first()
752 && val.contains('.')
753 {
754 deps.push(parse_symbolic_ref(scope, val));
755 return;
756 }
757
758 if let Some(Tok::Str(val)) = tokens.first()
760 && val.contains(':')
761 {
762 deps.push(parse_colon_string(val, scope));
763 }
764}
765
766fn parse_bracket_maps(tokens: &[Tok], deps: &mut Vec<RawDep>) {
767 let mut i = 0;
768 while i < tokens.len() {
769 if tokens[i] == Tok::OpenBracket
770 && let Some(end) = find_matching_bracket(tokens, i)
771 {
772 let map_tokens = &tokens[i + 1..end];
773 if let Some(rd) = parse_map_entries(map_tokens)
774 && !contains_equivalent_map_dep(deps, &rd)
775 {
776 deps.push(rd);
777 }
778 i = end + 1;
779 continue;
780 }
781 i += 1;
782 }
783}
784
785fn contains_equivalent_map_dep(existing: &[RawDep], candidate: &RawDep) -> bool {
786 existing.iter().any(|dep| {
787 dep.name == candidate.name
788 && dep.version == candidate.version
789 && dep.scope == candidate.scope
790 && (dep.namespace == candidate.namespace
791 || dep.namespace.is_empty()
792 || candidate.namespace.is_empty())
793 })
794}
795
796fn parse_map_entries(tokens: &[Tok]) -> Option<RawDep> {
797 let mut name = String::new();
798 let mut version = String::new();
799 let mut i = 0;
800
801 while i < tokens.len() {
802 if let Tok::Ident(ref label) = tokens[i]
803 && i + 2 < tokens.len()
804 && tokens[i + 1] == Tok::Colon
805 && let Tok::Str(ref val) = tokens[i + 2]
806 {
807 match label.as_str() {
808 "name" => name = truncate_field(val.clone()),
809 "version" => version = truncate_field(val.clone()),
810 _ => {}
811 }
812 i += 3;
813 if i < tokens.len() && tokens[i] == Tok::Comma {
814 i += 1;
815 }
816 continue;
817 }
818 i += 1;
819 }
820
821 if name.is_empty() {
822 return None;
823 }
824
825 Some(RawDep {
826 namespace: String::new(),
827 name,
828 version,
829 scope: String::new(),
830 catalog_alias: None,
831 symbolic_ref: None,
832 project_path: None,
833 })
834}
835
836fn parse_named_params(scope: &str, tokens: &[Tok]) -> Option<(RawDep, usize)> {
837 let mut group = String::new();
838 let mut name = String::new();
839 let mut version = String::new();
840 let mut i = 0;
841
842 while i < tokens.len() {
843 if let Tok::Ident(ref label) = tokens[i]
844 && i + 2 < tokens.len()
845 && tokens[i + 1] == Tok::Colon
846 && let Tok::Str(ref val) = tokens[i + 2]
847 {
848 match label.as_str() {
849 "group" => group = truncate_field(val.clone()),
850 "name" => name = truncate_field(val.clone()),
851 "version" => version = truncate_field(val.clone()),
852 _ => {}
853 }
854 i += 3;
855 if i < tokens.len() && tokens[i] == Tok::Comma {
856 i += 1;
857 }
858 continue;
859 }
860 break;
861 }
862
863 if name.is_empty() {
864 return None;
865 }
866
867 Some((
868 RawDep {
869 namespace: group,
870 name,
871 version,
872 scope: scope.to_string(),
873 catalog_alias: None,
874 symbolic_ref: None,
875 project_path: None,
876 },
877 i,
878 ))
879}
880
881fn parse_project_ref(tokens: &[Tok], scope: &str) -> Option<RawDep> {
882 if let Some(Tok::Str(val)) = tokens.first() {
883 let module_name = val.trim_start_matches(':');
884 let mut segments = module_name
885 .split(':')
886 .filter(|segment| !segment.is_empty())
887 .collect::<Vec<_>>();
888 let name = segments.pop().unwrap_or(module_name);
889 if name.is_empty() {
890 return None;
891 }
892 return Some(RawDep {
893 namespace: if segments.is_empty() {
894 String::new()
895 } else {
896 truncate_field(segments.join("/"))
897 },
898 name: truncate_field(name.to_string()),
899 version: String::new(),
900 scope: truncate_field(scope.to_string()),
901 catalog_alias: None,
902 symbolic_ref: None,
903 project_path: Some(truncate_field(module_name.to_string())),
904 });
905 }
906 None
907}
908
909fn parse_symbolic_ref(scope: &str, value: &str) -> RawDep {
910 RawDep {
911 namespace: String::new(),
912 name: String::new(),
913 version: String::new(),
914 scope: truncate_field(scope.to_string()),
915 catalog_alias: None,
916 symbolic_ref: Some(truncate_field(value.to_string())),
917 project_path: None,
918 }
919}
920
921fn parse_colon_string(val: &str, scope: &str) -> RawDep {
922 let parts: Vec<&str> = val.split(':').collect();
923 let (namespace, name, version) = match parts.len() {
924 n if n >= 4 => (
925 truncate_field(parts[0].to_string()),
926 truncate_field(parts[1].to_string()),
927 truncate_field(parts[2].to_string()),
928 ),
929 3 => (
930 truncate_field(parts[0].to_string()),
931 truncate_field(parts[1].to_string()),
932 truncate_field(parts[2].to_string()),
933 ),
934 2 => (
935 truncate_field(parts[0].to_string()),
936 truncate_field(parts[1].to_string()),
937 String::new(),
938 ),
939 _ => (
940 String::new(),
941 truncate_field(val.to_string()),
942 String::new(),
943 ),
944 };
945
946 RawDep {
947 namespace,
948 name,
949 version,
950 scope: truncate_field(scope.to_string()),
951 catalog_alias: None,
952 symbolic_ref: None,
953 project_path: None,
954 }
955}
956
957fn find_matching_paren(tokens: &[Tok], start: usize) -> Option<usize> {
958 if tokens.get(start) != Some(&Tok::OpenParen) {
959 return None;
960 }
961 let mut depth = 1;
962 let mut i = start + 1;
963 while i < tokens.len() && depth > 0 {
964 match &tokens[i] {
965 Tok::OpenParen => {
966 depth += 1;
967 if depth > MAX_RECURSION_DEPTH {
968 warn!(
969 "Gradle parser: nesting depth exceeded {} in find_matching_paren",
970 MAX_RECURSION_DEPTH
971 );
972 break;
973 }
974 }
975 Tok::CloseParen => depth -= 1,
976 _ => {}
977 }
978 if depth == 0 {
979 return Some(i);
980 }
981 i += 1;
982 }
983 None
984}
985
986fn find_matching_bracket(tokens: &[Tok], start: usize) -> Option<usize> {
987 if tokens.get(start) != Some(&Tok::OpenBracket) {
988 return None;
989 }
990 let mut depth = 1;
991 let mut i = start + 1;
992 while i < tokens.len() && depth > 0 {
993 match &tokens[i] {
994 Tok::OpenBracket => {
995 depth += 1;
996 if depth > MAX_RECURSION_DEPTH {
997 warn!(
998 "Gradle parser: nesting depth exceeded {} in find_matching_bracket",
999 MAX_RECURSION_DEPTH
1000 );
1001 break;
1002 }
1003 }
1004 Tok::CloseBracket => depth -= 1,
1005 _ => {}
1006 }
1007 if depth == 0 {
1008 return Some(i);
1009 }
1010 i += 1;
1011 }
1012 None
1013}
1014
1015fn create_dependency(raw: &RawDep) -> Option<Dependency> {
1020 let namespace = raw.namespace.as_str();
1021 let name = raw.name.as_str();
1022 let version = raw.version.as_str();
1023 let scope = raw.scope.as_str();
1024 if name.is_empty() {
1025 return None;
1026 }
1027
1028 let mut purl = PackageUrl::new("maven", name).ok()?;
1029
1030 if !namespace.is_empty() {
1031 purl.with_namespace(namespace).ok()?;
1032 }
1033
1034 if !version.is_empty() {
1035 purl.with_version(version).ok()?;
1036 }
1037
1038 let (is_runtime, is_optional) = classify_scope(scope);
1039 let is_pinned = !version.is_empty();
1040
1041 let purl_string = truncate_field(purl.to_string().replace("$", "%24").replace('\'', "%27"));
1042 let mut extra_data = std::collections::HashMap::new();
1043 if let Some(alias) = &raw.catalog_alias {
1044 extra_data.insert(
1045 "catalog_alias".to_string(),
1046 json!(truncate_field(alias.clone())),
1047 );
1048 }
1049 if let Some(project_path) = &raw.project_path {
1050 extra_data.insert(
1051 "project_path".to_string(),
1052 json!(truncate_field(project_path.clone())),
1053 );
1054 }
1055 if let Some(symbolic_ref) = &raw.symbolic_ref {
1056 extra_data.insert(
1057 "symbolic_ref".to_string(),
1058 json!(truncate_field(symbolic_ref.clone())),
1059 );
1060 }
1061
1062 Some(Dependency {
1063 purl: Some(purl_string),
1064 extracted_requirement: Some(truncate_field(version.to_string())),
1065 scope: Some(truncate_field(scope.to_string())),
1066 is_runtime: Some(is_runtime),
1067 is_optional: Some(is_optional),
1068 is_pinned: Some(is_pinned),
1069 is_direct: Some(true),
1070 resolved_package: None,
1071 extra_data: (!extra_data.is_empty()).then_some(extra_data),
1072 })
1073}
1074
1075fn classify_scope(scope: &str) -> (bool, bool) {
1076 let scope_lower = scope.to_lowercase();
1077
1078 if scope_lower.contains("test") {
1079 return (false, true);
1080 }
1081
1082 if matches!(
1083 scope_lower.as_str(),
1084 "compileonly" | "compileonlyapi" | "annotationprocessor" | "kapt" | "ksp"
1085 ) {
1086 return (false, false);
1087 }
1088
1089 (true, false)
1090}
1091
1092fn resolve_gradle_script_interpolations(
1093 path: &Path,
1094 content: &str,
1095 raw_dependencies: &mut [RawDep],
1096) {
1097 let properties = load_gradle_script_properties(path, content);
1098 if properties.is_empty() {
1099 return;
1100 }
1101
1102 for raw in raw_dependencies.iter_mut() {
1103 raw.namespace = interpolate_gradle_string(&raw.namespace, &properties);
1104 raw.name = interpolate_gradle_string(&raw.name, &properties);
1105 raw.version = interpolate_gradle_string(&raw.version, &properties);
1106 }
1107}
1108
1109fn load_gradle_script_properties(path: &Path, content: &str) -> HashMap<String, String> {
1110 let mut properties = load_gradle_properties(path);
1111
1112 let literal_assignment_patterns = [
1113 regex::Regex::new(
1114 r#"(?m)^\s*(?:const\s+)?(?:val|var|def)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=\s*['\"]([^'\"]+)['\"]"#,
1115 )
1116 .expect("valid regex"),
1117 regex::Regex::new(r#"(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['\"]([^'\"]+)['\"]"#)
1118 .expect("valid regex"),
1119 ];
1120
1121 for pattern in literal_assignment_patterns {
1122 for captures in pattern.captures_iter(content).take(MAX_ITERATION_COUNT) {
1123 let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1124 continue;
1125 };
1126 let Some(raw_value) = captures.get(2).map(|value| value.as_str()) else {
1127 continue;
1128 };
1129 let resolved = interpolate_gradle_string(raw_value, &properties);
1130 properties.insert(name.to_string(), resolved);
1131 }
1132 }
1133
1134 let delegated_project_property_pattern = regex::Regex::new(
1135 r#"(?m)^\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?\s+by\s+project\b"#,
1136 )
1137 .expect("valid regex");
1138
1139 for captures in delegated_project_property_pattern
1140 .captures_iter(content)
1141 .take(MAX_ITERATION_COUNT)
1142 {
1143 let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1144 continue;
1145 };
1146 if let Some(value) = properties.get(name).cloned() {
1147 properties.insert(name.to_string(), value);
1148 }
1149 }
1150
1151 properties
1152}
1153
1154fn load_gradle_properties(path: &Path) -> HashMap<String, String> {
1155 for ancestor in path.ancestors() {
1156 let gradle_properties = ancestor.join("gradle.properties");
1157 if !gradle_properties.is_file() {
1158 continue;
1159 }
1160
1161 let Ok(content) = read_file_to_string(&gradle_properties, None) else {
1162 continue;
1163 };
1164
1165 let mut properties = HashMap::new();
1166 for line in content.lines().take(MAX_ITERATION_COUNT) {
1167 let trimmed = line.split('#').next().unwrap_or("").trim();
1168 if trimmed.is_empty() {
1169 continue;
1170 }
1171
1172 let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':'))
1173 else {
1174 continue;
1175 };
1176
1177 let key = key.trim();
1178 let value = value.trim();
1179 if key.is_empty() || value.is_empty() {
1180 continue;
1181 }
1182 properties.insert(key.to_string(), value.to_string());
1183 }
1184 return properties;
1185 }
1186
1187 HashMap::new()
1188}
1189
1190fn interpolate_gradle_string(value: &str, properties: &HashMap<String, String>) -> String {
1191 if !value.contains('$') {
1192 return truncate_field(value.to_string());
1193 }
1194
1195 let chars = value.chars().collect::<Vec<_>>();
1196 let mut rendered = String::new();
1197 let mut i = 0;
1198
1199 while i < chars.len() {
1200 if chars[i] != '$' {
1201 rendered.push(chars[i]);
1202 i += 1;
1203 continue;
1204 }
1205
1206 if i + 1 >= chars.len() {
1207 rendered.push(chars[i]);
1208 break;
1209 }
1210
1211 if chars[i + 1] == '{' {
1212 let start = i;
1213 i += 2;
1214 let mut reference = String::new();
1215 while i < chars.len() && chars[i] != '}' {
1216 reference.push(chars[i]);
1217 i += 1;
1218 }
1219 if i < chars.len() && chars[i] == '}' {
1220 i += 1;
1221 }
1222
1223 if let Some(resolved) = properties.get(reference.trim()) {
1224 rendered.push_str(resolved);
1225 } else {
1226 rendered.push_str(&value[start..i]);
1227 }
1228 continue;
1229 }
1230
1231 let start = i;
1232 i += 1;
1233 let mut reference = String::new();
1234 while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_') {
1235 reference.push(chars[i]);
1236 i += 1;
1237 }
1238
1239 if reference.is_empty() {
1240 rendered.push('$');
1241 continue;
1242 }
1243
1244 if let Some(resolved) = properties.get(reference.as_str()) {
1245 rendered.push_str(resolved);
1246 } else {
1247 rendered.push_str(&value[start..i]);
1248 }
1249 }
1250
1251 truncate_field(rendered)
1252}
1253
1254fn resolve_gradle_buildsrc_symbolic_refs(path: &Path, raw_dependencies: &mut [RawDep]) {
1255 let ancestor_build_src_dir = find_build_src_dir(path);
1256 let ancestor_constants = ancestor_build_src_dir
1257 .as_deref()
1258 .and_then(load_build_src_constants);
1259 let sibling_build_src_tiers = if ancestor_build_src_dir.is_none() {
1260 find_nearby_sibling_build_src_tiers(path)
1261 } else {
1262 Vec::new()
1263 };
1264
1265 for raw in raw_dependencies.iter_mut() {
1266 let Some(symbolic_ref) = raw.symbolic_ref.as_deref() else {
1267 continue;
1268 };
1269
1270 let resolved = ancestor_constants
1271 .as_ref()
1272 .and_then(|constants| {
1273 let mut visiting = HashSet::new();
1274 resolve_build_src_value(symbolic_ref, constants, &mut visiting)
1275 })
1276 .or_else(|| {
1277 resolve_nearby_sibling_build_src_value(symbolic_ref, &sibling_build_src_tiers)
1278 });
1279 let Some(resolved) = resolved else {
1280 continue;
1281 };
1282 if !resolved.contains(':') {
1283 continue;
1284 }
1285
1286 let resolved_dependency = parse_colon_string(&resolved, &raw.scope);
1287 raw.namespace = resolved_dependency.namespace;
1288 raw.name = resolved_dependency.name;
1289 raw.version = resolved_dependency.version;
1290 }
1291}
1292
1293fn find_build_src_dir(path: &Path) -> Option<PathBuf> {
1294 for ancestor in path.ancestors() {
1295 let build_src_dir = ancestor.join("buildSrc");
1296 if build_src_dir.is_dir() {
1297 return Some(build_src_dir);
1298 }
1299 }
1300 None
1301}
1302
1303fn find_nearby_sibling_build_src_tiers(path: &Path) -> Vec<Vec<PathBuf>> {
1304 let mut tiers = Vec::new();
1305
1306 for ancestor in path.ancestors().skip(1).take(MAX_ITERATION_COUNT) {
1307 let sibling_dirs = collect_sibling_build_src_dirs(ancestor, path);
1308 if !sibling_dirs.is_empty() {
1309 tiers.push(sibling_dirs);
1310 }
1311 }
1312
1313 tiers
1314}
1315
1316fn collect_sibling_build_src_dirs(ancestor: &Path, current_path: &Path) -> Vec<PathBuf> {
1317 if !ancestor.is_dir() {
1318 return Vec::new();
1319 }
1320
1321 let Ok(entries) = std::fs::read_dir(ancestor) else {
1322 return Vec::new();
1323 };
1324
1325 let mut build_src_dirs = Vec::new();
1326 for entry in entries.flatten().take(MAX_ITERATION_COUNT) {
1327 let child_dir = entry.path();
1328 if !child_dir.is_dir() || current_path.starts_with(&child_dir) {
1329 continue;
1330 }
1331
1332 let build_src_dir = child_dir.join("buildSrc");
1333 if !build_src_dir.is_dir() || !has_gradle_settings_file(&child_dir) {
1334 continue;
1335 }
1336
1337 build_src_dirs.push(build_src_dir);
1338 }
1339
1340 build_src_dirs.sort();
1341 build_src_dirs
1342}
1343
1344fn has_gradle_settings_file(dir: &Path) -> bool {
1345 dir.join("settings.gradle").is_file() || dir.join("settings.gradle.kts").is_file()
1346}
1347
1348fn resolve_nearby_sibling_build_src_value(
1349 symbolic_ref: &str,
1350 sibling_build_src_tiers: &[Vec<PathBuf>],
1351) -> Option<String> {
1352 for sibling_build_src_dirs in sibling_build_src_tiers.iter().take(MAX_ITERATION_COUNT) {
1353 let mut resolved_value: Option<String> = None;
1354
1355 for build_src_dir in sibling_build_src_dirs.iter().take(MAX_ITERATION_COUNT) {
1356 let Some(constants) = load_build_src_constants(build_src_dir) else {
1357 continue;
1358 };
1359
1360 let mut visiting = HashSet::new();
1361 let Some(candidate) = resolve_build_src_value(symbolic_ref, &constants, &mut visiting)
1362 else {
1363 continue;
1364 };
1365 if !candidate.contains(':') {
1366 continue;
1367 }
1368
1369 match &resolved_value {
1370 None => resolved_value = Some(candidate),
1371 Some(existing) if existing == &candidate => {}
1372 Some(_) => return None,
1373 }
1374 }
1375
1376 if resolved_value.is_some() {
1377 return resolved_value;
1378 }
1379 }
1380
1381 None
1382}
1383
1384fn load_build_src_constants(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1385 let cache = BUILD_SRC_CONSTANT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1386 if let Ok(guard) = cache.lock()
1387 && let Some(cached) = guard.get(build_src_dir)
1388 {
1389 return cached.clone();
1390 }
1391
1392 let parsed = parse_build_src_constants_dir(build_src_dir);
1393
1394 if let Ok(mut guard) = cache.lock() {
1395 guard.insert(build_src_dir.to_path_buf(), parsed.clone());
1396 }
1397
1398 parsed
1399}
1400
1401fn parse_build_src_constants_dir(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1402 let mut kotlin_files = Vec::new();
1403 for source_dir in [
1404 build_src_dir.join("src").join("main").join("java"),
1405 build_src_dir.join("src").join("main").join("kotlin"),
1406 ] {
1407 collect_build_src_kotlin_files(&source_dir, &mut kotlin_files);
1408 }
1409
1410 if kotlin_files.is_empty() {
1411 return None;
1412 }
1413
1414 let mut constants = HashMap::new();
1415 for file in kotlin_files.into_iter().take(MAX_ITERATION_COUNT) {
1416 let Ok(content) = read_file_to_string(&file, None) else {
1417 continue;
1418 };
1419 constants.extend(parse_build_src_constants(&content));
1420 }
1421
1422 (!constants.is_empty()).then_some(constants)
1423}
1424
1425fn collect_build_src_kotlin_files(dir: &Path, files: &mut Vec<PathBuf>) {
1426 if files.len() >= MAX_ITERATION_COUNT || !dir.is_dir() {
1427 return;
1428 }
1429
1430 let Ok(entries) = std::fs::read_dir(dir) else {
1431 return;
1432 };
1433
1434 for entry in entries.flatten().take(MAX_ITERATION_COUNT) {
1435 if files.len() >= MAX_ITERATION_COUNT {
1436 break;
1437 }
1438
1439 let path = entry.path();
1440 if path.is_dir() {
1441 collect_build_src_kotlin_files(&path, files);
1442 continue;
1443 }
1444
1445 if path.extension().is_some_and(|ext| ext == "kt") {
1446 files.push(path);
1447 }
1448 }
1449}
1450
1451fn parse_build_src_constants(content: &str) -> BuildSrcConstMap {
1452 let tokens = lex(content);
1453 let mut constants = HashMap::new();
1454 let mut object_stack = Vec::new();
1455 let mut brace_stack: Vec<Option<String>> = Vec::new();
1456 let mut i = 0;
1457
1458 while i < tokens.len() && i < MAX_ITERATION_COUNT {
1459 if let Some((name, consumed)) = parse_object_declaration(&tokens[i..]) {
1460 object_stack.push(name.clone());
1461 brace_stack.push(Some(name));
1462 i += consumed;
1463 continue;
1464 }
1465
1466 if let Some((name, expr, consumed)) = parse_build_src_const_definition(&tokens[i..]) {
1467 let scope = object_stack.join(".");
1468 let full_name = if scope.is_empty() {
1469 name.clone()
1470 } else {
1471 format!("{scope}.{name}")
1472 };
1473 constants.insert(
1474 truncate_field(full_name),
1475 BuildSrcConst {
1476 scope: truncate_field(scope),
1477 expr,
1478 },
1479 );
1480 i += consumed;
1481 continue;
1482 }
1483
1484 match &tokens[i] {
1485 Tok::OpenBrace => brace_stack.push(None),
1486 Tok::CloseBrace => {
1487 if let Some(marker) = brace_stack.pop()
1488 && marker.is_some()
1489 {
1490 object_stack.pop();
1491 }
1492 }
1493 _ => {}
1494 }
1495
1496 i += 1;
1497 }
1498
1499 constants
1500}
1501
1502fn parse_object_declaration(tokens: &[Tok]) -> Option<(String, usize)> {
1503 if let [Tok::Ident(keyword), Tok::Ident(name), Tok::OpenBrace, ..] = tokens
1504 && keyword == "object"
1505 {
1506 return Some((truncate_field(name.clone()), 3));
1507 }
1508 None
1509}
1510
1511fn parse_build_src_const_definition(tokens: &[Tok]) -> Option<(String, BuildSrcExpr, usize)> {
1512 let mut cursor = 0;
1513
1514 while let Some(Tok::Ident(modifier)) = tokens.get(cursor) {
1515 if matches!(
1516 modifier.as_str(),
1517 "private" | "internal" | "public" | "protected"
1518 ) {
1519 cursor += 1;
1520 continue;
1521 }
1522 break;
1523 }
1524
1525 if !matches!(tokens.get(cursor), Some(Tok::Ident(keyword)) if keyword == "const")
1526 || !matches!(tokens.get(cursor + 1), Some(Tok::Ident(keyword)) if keyword == "val")
1527 {
1528 return None;
1529 }
1530
1531 let Tok::Ident(name) = tokens.get(cursor + 2)? else {
1532 return None;
1533 };
1534 if tokens.get(cursor + 3) != Some(&Tok::Equals) {
1535 return None;
1536 }
1537
1538 let expr = match tokens.get(cursor + 4)? {
1539 Tok::Str(value) => BuildSrcExpr::Literal(truncate_field(value.clone())),
1540 Tok::Ident(value) => BuildSrcExpr::Ref(truncate_field(value.clone())),
1541 _ => return None,
1542 };
1543
1544 Some((truncate_field(name.clone()), expr, cursor + 5))
1545}
1546
1547fn resolve_build_src_value(
1548 key: &str,
1549 constants: &BuildSrcConstMap,
1550 visiting: &mut HashSet<String>,
1551) -> Option<String> {
1552 if !visiting.insert(key.to_string()) {
1553 return None;
1554 }
1555
1556 let resolved = constants
1557 .get(key)
1558 .and_then(|constant| resolve_build_src_expr(constant, constants, visiting));
1559 visiting.remove(key);
1560 resolved
1561}
1562
1563fn resolve_build_src_expr(
1564 constant: &BuildSrcConst,
1565 constants: &BuildSrcConstMap,
1566 visiting: &mut HashSet<String>,
1567) -> Option<String> {
1568 match &constant.expr {
1569 BuildSrcExpr::Literal(value) => Some(interpolate_build_src_string(
1570 value,
1571 &constant.scope,
1572 constants,
1573 visiting,
1574 )),
1575 BuildSrcExpr::Ref(reference) => {
1576 resolve_build_src_symbol(&constant.scope, reference, constants, visiting)
1577 }
1578 }
1579}
1580
1581fn resolve_build_src_symbol(
1582 scope: &str,
1583 reference: &str,
1584 constants: &BuildSrcConstMap,
1585 visiting: &mut HashSet<String>,
1586) -> Option<String> {
1587 if reference.contains('.') {
1588 return resolve_build_src_value(reference, constants, visiting);
1589 }
1590
1591 let mut current_scope = Some(scope);
1592 while let Some(scope_name) = current_scope {
1593 if !scope_name.is_empty() {
1594 let candidate = format!("{scope_name}.{reference}");
1595 if let Some(value) = resolve_build_src_value(&candidate, constants, visiting) {
1596 return Some(value);
1597 }
1598 }
1599
1600 current_scope = scope_name.rsplit_once('.').map(|(parent, _)| parent);
1601 }
1602
1603 resolve_build_src_value(reference, constants, visiting)
1604}
1605
1606fn interpolate_build_src_string(
1607 value: &str,
1608 scope: &str,
1609 constants: &BuildSrcConstMap,
1610 visiting: &mut HashSet<String>,
1611) -> String {
1612 let chars = value.chars().collect::<Vec<_>>();
1613 let mut rendered = String::new();
1614 let mut i = 0;
1615
1616 while i < chars.len() {
1617 if chars[i] != '$' {
1618 rendered.push(chars[i]);
1619 i += 1;
1620 continue;
1621 }
1622
1623 if i + 1 >= chars.len() {
1624 rendered.push(chars[i]);
1625 break;
1626 }
1627
1628 if chars[i + 1] == '{' {
1629 let start = i;
1630 i += 2;
1631 let mut reference = String::new();
1632 while i < chars.len() && chars[i] != '}' {
1633 reference.push(chars[i]);
1634 i += 1;
1635 }
1636 if i < chars.len() && chars[i] == '}' {
1637 i += 1;
1638 }
1639
1640 if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting)
1641 {
1642 rendered.push_str(&resolved);
1643 } else {
1644 rendered.push_str(&value[start..i]);
1645 }
1646 continue;
1647 }
1648
1649 let start = i;
1650 i += 1;
1651 let mut reference = String::new();
1652 while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.') {
1653 reference.push(chars[i]);
1654 i += 1;
1655 }
1656
1657 if reference.is_empty() {
1658 rendered.push('$');
1659 continue;
1660 }
1661
1662 if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting) {
1663 rendered.push_str(&resolved);
1664 } else {
1665 rendered.push_str(&value[start..i]);
1666 }
1667 }
1668
1669 truncate_field(rendered)
1670}
1671
1672#[derive(Debug, Clone)]
1673struct GradleCatalogEntry {
1674 namespace: String,
1675 name: String,
1676 version: Option<String>,
1677}
1678
1679fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
1680 let Some(catalog_path) = find_gradle_version_catalog(path) else {
1681 return;
1682 };
1683 let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
1684 return;
1685 };
1686
1687 for dep in dependencies.iter_mut() {
1688 let alias = dep
1689 .extra_data
1690 .as_ref()
1691 .and_then(|data| data.get("catalog_alias"))
1692 .and_then(|value| value.as_str());
1693 let Some(alias) = alias else {
1694 continue;
1695 };
1696 let Some(entry) = entries.get(alias) else {
1697 continue;
1698 };
1699
1700 let mut purl = PackageUrl::new("maven", &entry.name).ok();
1701 if let Some(ref mut purl) = purl {
1702 if !entry.namespace.is_empty() {
1703 let _ = purl.with_namespace(&entry.namespace);
1704 }
1705 if let Some(version) = &entry.version {
1706 let _ = purl.with_version(version);
1707 }
1708 }
1709
1710 dep.purl = purl.map(|p| truncate_field(p.to_string()));
1711 dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1712 dep.is_pinned = Some(entry.version.is_some());
1713 }
1714}
1715
1716fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1717 for ancestor in path.ancestors() {
1718 let nested = ancestor.join("gradle").join("libs.versions.toml");
1719 if nested.is_file() {
1720 return Some(nested);
1721 }
1722
1723 let sibling = ancestor.join("libs.versions.toml");
1724 if sibling.is_file() {
1725 return Some(sibling);
1726 }
1727 }
1728
1729 None
1730}
1731
1732fn parse_gradle_version_catalog(
1733 path: &Path,
1734) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1735 let content = read_file_to_string(path, None).ok()?;
1736 let mut section = "";
1737 let mut versions = std::collections::HashMap::new();
1738 let mut libraries = std::collections::HashMap::new();
1739
1740 for line in content.lines().take(MAX_ITERATION_COUNT) {
1741 let trimmed = line.split('#').next().unwrap_or("").trim();
1742 if trimmed.is_empty() {
1743 continue;
1744 }
1745
1746 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1747 section = trimmed.trim_matches(&['[', ']'][..]);
1748 continue;
1749 }
1750
1751 let Some((key, value)) = trimmed.split_once('=') else {
1752 continue;
1753 };
1754 let key = key.trim().to_string();
1755 let value = value.trim().to_string();
1756
1757 match section {
1758 "versions" => {
1759 versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1760 }
1761 "libraries" => {
1762 libraries.insert(key, value);
1763 }
1764 _ => {}
1765 }
1766 }
1767
1768 let mut result = std::collections::HashMap::new();
1769 for (alias, raw_value) in libraries.into_iter().take(MAX_ITERATION_COUNT) {
1770 let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1771 continue;
1772 };
1773 result.insert(truncate_field(alias.replace('-', ".")), entry);
1774 }
1775
1776 Some(result)
1777}
1778
1779fn parse_gradle_catalog_entry(
1780 raw_value: &str,
1781 versions: &std::collections::HashMap<String, String>,
1782) -> Option<GradleCatalogEntry> {
1783 if raw_value.starts_with('"') && raw_value.ends_with('"') {
1784 let notation = strip_quotes(raw_value);
1785 let mut parts = notation.split(':');
1786 let namespace = truncate_field(parts.next()?.to_string());
1787 let name = truncate_field(parts.next()?.to_string());
1788 let version = parts.next().map(|v| truncate_field(v.to_string()));
1789 return Some(GradleCatalogEntry {
1790 namespace,
1791 name,
1792 version,
1793 });
1794 }
1795
1796 if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1797 return None;
1798 }
1799
1800 let inner = &raw_value[1..raw_value.len() - 1];
1801 let mut fields = std::collections::HashMap::new();
1802 for pair in inner.split(',').take(MAX_ITERATION_COUNT) {
1803 let Some((key, value)) = pair.split_once('=') else {
1804 continue;
1805 };
1806 fields.insert(
1807 truncate_field(key.trim().to_string()),
1808 truncate_field(strip_quotes(value.trim()).to_string()),
1809 );
1810 }
1811
1812 let (namespace, name) = if let Some(module) = fields.get("module") {
1813 let (group, artifact) = module.split_once(':')?;
1814 (
1815 truncate_field(group.to_string()),
1816 truncate_field(artifact.to_string()),
1817 )
1818 } else {
1819 (
1820 truncate_field(fields.get("group")?.to_string()),
1821 truncate_field(fields.get("name")?.to_string()),
1822 )
1823 };
1824
1825 let version = if let Some(version) = fields.get("version") {
1826 Some(truncate_field(version.to_string()))
1827 } else if let Some(version_ref) = fields.get("version.ref") {
1828 versions.get(version_ref).cloned().map(truncate_field)
1829 } else {
1830 None
1831 };
1832
1833 Some(GradleCatalogEntry {
1834 namespace,
1835 name,
1836 version,
1837 })
1838}
1839
1840fn strip_quotes(value: &str) -> &str {
1841 value
1842 .strip_prefix('"')
1843 .and_then(|v| v.strip_suffix('"'))
1844 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1845 .unwrap_or(value)
1846}
1847
1848fn extract_gradle_license_metadata(
1849 tokens: &[Tok],
1850) -> (
1851 Option<String>,
1852 Option<String>,
1853 Option<String>,
1854 Vec<crate::models::LicenseDetection>,
1855) {
1856 let mut i = 0;
1857 while i < tokens.len() {
1858 if let Tok::Ident(name) = &tokens[i]
1859 && name == "licenses"
1860 && i + 1 < tokens.len()
1861 && tokens[i + 1] == Tok::OpenBrace
1862 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1863 {
1864 let inner = &tokens[i + 2..block_end];
1865 if let Some((license_name, license_url)) = parse_license_block(inner) {
1866 let extracted =
1867 format_gradle_license_statement(&license_name, license_url.as_deref());
1868 let declared_candidate =
1869 derive_gradle_license_expression(&license_name, license_url.as_deref());
1870 if let Some(declared_candidate) = declared_candidate
1871 && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1872 {
1873 let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1874 let (declared, declared_spdx, detections) = build_declared_license_data(
1875 normalized,
1876 DeclaredLicenseMatchMetadata::single_line(matched_text),
1877 );
1878 return (
1879 extracted.map(truncate_field),
1880 declared.map(truncate_field),
1881 declared_spdx.map(truncate_field),
1882 detections,
1883 );
1884 }
1885
1886 return (
1887 extracted.map(truncate_field),
1888 None,
1889 None,
1890 empty_declared_license_data().2,
1891 );
1892 }
1893 i = block_end + 1;
1894 continue;
1895 }
1896 i += 1;
1897 }
1898
1899 (None, None, None, Vec::new())
1900}
1901
1902fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1903 let mut i = 0;
1904 while i < tokens.len() {
1905 if let Tok::Ident(name) = &tokens[i]
1906 && name == "license"
1907 && i + 1 < tokens.len()
1908 && tokens[i + 1] == Tok::OpenBrace
1909 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1910 {
1911 let mut license_name = None;
1912 let mut license_url = None;
1913 let block = &tokens[i + 2..block_end];
1914 let mut j = 0;
1915 while j < block.len() {
1916 if let Tok::Ident(label) = &block[j] {
1917 let normalized = label.strip_suffix(".set").unwrap_or(label);
1918 if (normalized == "name" || normalized == "url")
1919 && let Some(value) = next_string_literal(block, j + 1)
1920 {
1921 if normalized == "name" {
1922 license_name = Some(value);
1923 } else {
1924 license_url = Some(value);
1925 }
1926 }
1927 }
1928 j += 1;
1929 }
1930
1931 return license_name.map(|name| (name, license_url));
1932 }
1933 i += 1;
1934 }
1935 None
1936}
1937
1938fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1939 for token in tokens.iter().skip(start) {
1940 match token {
1941 Tok::Str(value) => return Some(truncate_field(value.clone())),
1942 Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1943 Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1944 _ => break,
1945 }
1946 }
1947 None
1948}
1949
1950fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1951 if tokens.get(start) != Some(&Tok::OpenBrace) {
1952 return None;
1953 }
1954 let mut depth = 1;
1955 let mut i = start + 1;
1956 while i < tokens.len() && depth > 0 {
1957 match &tokens[i] {
1958 Tok::OpenBrace => {
1959 depth += 1;
1960 if depth > MAX_RECURSION_DEPTH {
1961 warn!(
1962 "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1963 MAX_RECURSION_DEPTH
1964 );
1965 break;
1966 }
1967 }
1968 Tok::CloseBrace => depth -= 1,
1969 _ => {}
1970 }
1971 if depth == 0 {
1972 return Some(i);
1973 }
1974 i += 1;
1975 }
1976 None
1977}
1978
1979fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
1980 let mut output = format!("- license:\n name: {name}\n");
1981 if let Some(url) = url {
1982 output.push_str(&format!(" url: {url}\n"));
1983 }
1984 Some(truncate_field(output))
1985}
1986
1987fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
1988 let trimmed = name.trim();
1989 let candidates = [trimmed, url.unwrap_or("")];
1990
1991 for candidate in candidates {
1992 let lower = candidate.to_ascii_lowercase();
1993 if trimmed == "Apache-2.0"
1994 || lower.contains("apache-2.0")
1995 || lower.contains("apache license, version 2.0")
1996 || lower.contains("apache.org/licenses/license-2.0")
1997 {
1998 return Some(truncate_field("Apache-2.0".to_string()));
1999 }
2000 if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
2001 return Some(truncate_field("MIT".to_string()));
2002 }
2003 if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
2004 return Some(truncate_field(trimmed.to_string()));
2005 }
2006 }
2007
2008 None
2009}
2010
2011#[cfg(test)]
2012mod tests {
2013 use super::*;
2014 use tempfile::tempdir;
2015
2016 #[test]
2017 fn test_is_match() {
2018 assert!(GradleParser::is_match(Path::new("build.gradle")));
2019 assert!(GradleParser::is_match(Path::new("build.gradle.kts")));
2020 assert!(GradleParser::is_match(Path::new("project/build.gradle")));
2021 assert!(!GradleParser::is_match(Path::new("build.xml")));
2022 assert!(!GradleParser::is_match(Path::new("settings.gradle")));
2023 }
2024
2025 #[test]
2026 fn test_extract_simple_dependencies() {
2027 let content = r#"
2028dependencies {
2029 compile 'org.apache.commons:commons-text:1.1'
2030 testCompile 'junit:junit:4.12'
2031}
2032"#;
2033 let tokens = lex(content);
2034 let deps = extract_dependencies(&tokens);
2035 assert_eq!(deps.len(), 2);
2036
2037 let dep1 = &deps[0];
2038 assert_eq!(
2039 dep1.purl,
2040 Some("pkg:maven/org.apache.commons/commons-text@1.1".to_string())
2041 );
2042 assert_eq!(dep1.scope, Some("compile".to_string()));
2043 assert_eq!(dep1.is_runtime, Some(true));
2044 assert_eq!(dep1.is_pinned, Some(true));
2045
2046 let dep2 = &deps[1];
2047 assert_eq!(dep2.purl, Some("pkg:maven/junit/junit@4.12".to_string()));
2048 assert_eq!(dep2.scope, Some("testCompile".to_string()));
2049 assert_eq!(dep2.is_runtime, Some(false));
2050 assert_eq!(dep2.is_optional, Some(true));
2051 }
2052
2053 #[test]
2054 fn test_extract_parens_notation() {
2055 let content = r#"
2056dependencies {
2057 implementation("com.example:library:1.0.0")
2058 testImplementation("junit:junit:4.13")
2059}
2060"#;
2061 let tokens = lex(content);
2062 let deps = extract_dependencies(&tokens);
2063 assert_eq!(deps.len(), 2);
2064 assert_eq!(
2065 deps[0].purl,
2066 Some("pkg:maven/com.example/library@1.0.0".to_string())
2067 );
2068 }
2069
2070 #[test]
2071 fn test_extract_named_parameters() {
2072 let content = r#"
2073dependencies {
2074 api group: 'com.google.guava', name: 'guava', version: '30.1-jre'
2075}
2076"#;
2077 let tokens = lex(content);
2078 let deps = extract_dependencies(&tokens);
2079 assert_eq!(deps.len(), 1);
2080 assert_eq!(
2081 deps[0].purl,
2082 Some("pkg:maven/com.google.guava/guava@30.1-jre".to_string())
2083 );
2084 assert_eq!(deps[0].scope, Some("api".to_string()));
2085 }
2086
2087 #[test]
2088 fn test_multiple_dependency_blocks_all_parsed() {
2089 let content = r#"
2090dependencies {
2091 implementation 'org.scala-lang:scala-library:2.11.12'
2092}
2093
2094dependencies {
2095 implementation 'commons-collections:commons-collections:3.2.2'
2096 testImplementation 'junit:junit:4.13'
2097}
2098"#;
2099 let tokens = lex(content);
2100 let deps = extract_dependencies(&tokens);
2101 assert_eq!(deps.len(), 3);
2102 assert_eq!(
2103 deps[0].purl,
2104 Some("pkg:maven/org.scala-lang/scala-library@2.11.12".to_string())
2105 );
2106 assert_eq!(
2107 deps[1].purl,
2108 Some("pkg:maven/commons-collections/commons-collections@3.2.2".to_string())
2109 );
2110 assert_eq!(deps[2].purl, Some("pkg:maven/junit/junit@4.13".to_string()));
2111 assert_eq!(deps[2].scope, Some("testImplementation".to_string()));
2112 }
2113
2114 #[test]
2115 fn test_nested_dependency_blocks_all_parsed() {
2116 let content = r#"
2117buildscript {
2118 dependencies {
2119 classpath("org.eclipse.jgit:org.eclipse.jgit:$jgitVersion")
2120 }
2121}
2122
2123subprojects {
2124 dependencies {
2125 implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinPluginVersion")
2126 }
2127}
2128"#;
2129 let tokens = lex(content);
2130 let deps = extract_dependencies(&tokens);
2131
2132 assert_eq!(deps.len(), 2);
2133 assert_eq!(
2134 deps[0].purl,
2135 Some("pkg:maven/org.eclipse.jgit/org.eclipse.jgit@%24jgitVersion".to_string())
2136 );
2137 assert_eq!(deps[0].scope, Some("classpath".to_string()));
2138 assert_eq!(
2139 deps[1].purl,
2140 Some(
2141 "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib-jdk8@%24kotlinPluginVersion"
2142 .to_string()
2143 )
2144 );
2145 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2146 }
2147
2148 #[test]
2149 fn test_no_version() {
2150 let content = r#"
2151dependencies {
2152 compile 'org.example:library'
2153}
2154"#;
2155 let tokens = lex(content);
2156 let deps = extract_dependencies(&tokens);
2157 assert_eq!(deps.len(), 1);
2158 assert_eq!(deps[0].is_pinned, Some(false));
2159 assert_eq!(deps[0].extracted_requirement, Some("".to_string()));
2160 }
2161
2162 #[test]
2163 fn test_nested_function_calls() {
2164 let content = r#"
2165dependencies {
2166 implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.12.2"))
2167 testImplementation(platform("org.junit:junit-bom:5.7.2"))
2168}
2169"#;
2170 let tokens = lex(content);
2171 let deps = extract_dependencies(&tokens);
2172 assert_eq!(deps.len(), 2);
2173 assert_eq!(
2174 deps[0].purl,
2175 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2".to_string())
2176 );
2177 assert_eq!(deps[0].scope, Some("enforcedPlatform".to_string()));
2178 assert_eq!(deps[1].scope, Some("platform".to_string()));
2179 }
2180
2181 #[test]
2182 fn test_map_format() {
2183 let content = r#"
2184dependencies {
2185 runtimeOnly(
2186 [group: 'org.jacoco', name: 'org.jacoco.ant', version: '0.7.4.201502262128'],
2187 [group: 'org.jacoco', name: 'org.jacoco.agent', version: '0.7.4.201502262128']
2188 )
2189}
2190"#;
2191 let tokens = lex(content);
2192 let deps = extract_dependencies(&tokens);
2193 assert_eq!(deps.len(), 2);
2194 assert_eq!(deps[0].scope, Some("".to_string()));
2195 assert_eq!(
2196 deps[0].purl,
2197 Some("pkg:maven/org.jacoco.ant@0.7.4.201502262128".to_string())
2198 );
2199 }
2200
2201 #[test]
2202 fn test_bracket_map_dedupes_exact_string_overlap() {
2203 let content = r#"
2204dependencies {
2205 runtimeOnly 'org.springframework:spring-core:2.5',
2206 'org.springframework:spring-aop:2.5'
2207 runtimeOnly(
2208 [group: 'org.springframework', name: 'spring-core', version: '2.5'],
2209 [group: 'org.springframework', name: 'spring-aop', version: '2.5']
2210 )
2211}
2212"#;
2213
2214 let tokens = lex(content);
2215 let deps = extract_dependencies(&tokens);
2216 assert_eq!(deps.len(), 2);
2217 assert_eq!(
2218 deps[0].purl,
2219 Some("pkg:maven/org.springframework/spring-core@2.5".to_string())
2220 );
2221 assert_eq!(
2222 deps[1].purl,
2223 Some("pkg:maven/org.springframework/spring-aop@2.5".to_string())
2224 );
2225 }
2226
2227 #[test]
2228 fn test_malformed_string_stops_cascading_false_positives() {
2229 let content = r#"
2230dependencies {
2231 implementation "com.fasterxml.jackson:jackson-bom:2.12.2'
2232 implementation" com.fasterxml.jackson.core:jackson-core"
2233 testImplementation 'org.junit:junit-bom:5.7.2'"
2234 testImplementation "org.junit.platform:junit-platform-commons"
2235}
2236"#;
2237
2238 let tokens = lex(content);
2239 let deps = extract_dependencies(&tokens);
2240 assert_eq!(deps.len(), 1);
2241 assert_eq!(
2242 deps[0].purl,
2243 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2%27".to_string())
2244 );
2245 }
2246
2247 #[test]
2248 fn test_project_references() {
2249 let content = r#"
2250dependencies {
2251 implementation(project(":documentation"))
2252 implementation(project(":basics"))
2253}
2254"#;
2255 let tokens = lex(content);
2256 let deps = extract_dependencies(&tokens);
2257 assert_eq!(deps.len(), 2);
2258 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2259 assert_eq!(
2260 deps[0]
2261 .extra_data
2262 .as_ref()
2263 .and_then(|data| data.get("project_path"))
2264 .and_then(|value| value.as_str()),
2265 Some("documentation")
2266 );
2267 assert_eq!(deps[0].purl, Some("pkg:maven/documentation".to_string()));
2268 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2269 assert_eq!(
2270 deps[1]
2271 .extra_data
2272 .as_ref()
2273 .and_then(|data| data.get("project_path"))
2274 .and_then(|value| value.as_str()),
2275 Some("basics")
2276 );
2277 assert_eq!(deps[1].purl, Some("pkg:maven/basics".to_string()));
2278 }
2279
2280 #[test]
2281 fn test_nested_project_references_preserve_parent_path() {
2282 let content = r#"
2283dependencies {
2284 implementation(project(":libs:download"))
2285 implementation(project(":libs:index"))
2286}
2287"#;
2288 let tokens = lex(content);
2289 let deps = extract_dependencies(&tokens);
2290
2291 assert_eq!(deps.len(), 2);
2292 assert_eq!(deps[0].purl, Some("pkg:maven/libs/download".to_string()));
2293 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2294 assert_eq!(
2295 deps[0]
2296 .extra_data
2297 .as_ref()
2298 .and_then(|data| data.get("project_path"))
2299 .and_then(|value| value.as_str()),
2300 Some("libs:download")
2301 );
2302 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2303 assert_eq!(
2304 deps[1]
2305 .extra_data
2306 .as_ref()
2307 .and_then(|data| data.get("project_path"))
2308 .and_then(|value| value.as_str()),
2309 Some("libs:index")
2310 );
2311 assert_eq!(deps[1].purl, Some("pkg:maven/libs/index".to_string()));
2312 }
2313
2314 #[test]
2315 fn test_testimplementation_project_reference_is_not_runtime() {
2316 let content = r#"
2317dependencies {
2318 testImplementation project(':mockito-config')
2319}
2320"#;
2321 let tokens = lex(content);
2322 let deps = extract_dependencies(&tokens);
2323
2324 assert_eq!(deps.len(), 1);
2325 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2326 assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2327 assert_eq!(deps[0].is_runtime, Some(false));
2328 assert_eq!(deps[0].is_optional, Some(true));
2329 assert_eq!(
2330 deps[0]
2331 .extra_data
2332 .as_ref()
2333 .and_then(|data| data.get("project_path"))
2334 .and_then(|value| value.as_str()),
2335 Some("mockito-config")
2336 );
2337 }
2338
2339 #[test]
2340 fn test_unresolved_dotted_identifiers_are_ignored_but_project_refs_survive() {
2341 let content = r#"
2342dependencies {
2343 implementation Deps.AndroidX.core
2344 implementation Deps.AndroidX.androidxAnnotation
2345 testImplementation TestDeps.mockitoCore3
2346 testImplementation project(':mockito-config')
2347}
2348"#;
2349 let tokens = lex(content);
2350 let deps = extract_dependencies(&tokens);
2351
2352 assert_eq!(deps.len(), 1);
2353 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2354 assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2355 assert_eq!(deps[0].is_runtime, Some(false));
2356 assert_eq!(deps[0].is_optional, Some(true));
2357 assert_eq!(
2358 deps[0]
2359 .extra_data
2360 .as_ref()
2361 .and_then(|data| data.get("project_path"))
2362 .and_then(|value| value.as_str()),
2363 Some("mockito-config")
2364 );
2365 }
2366
2367 #[test]
2368 fn test_buildsrc_kotlin_constants_resolve_from_committed_files() {
2369 let temp_dir = tempdir().unwrap();
2370 let build_src_dir = temp_dir
2371 .path()
2372 .join("buildSrc/src/main/java/com/example/buildsrc");
2373 std::fs::create_dir_all(&build_src_dir).unwrap();
2374 std::fs::write(
2375 build_src_dir.join("GradleDeps.kt"),
2376 r#"
2377object GradleDeps {
2378 object Kotlin {
2379 const val version = "2.0.0"
2380 const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version"
2381 }
2382}
2383"#,
2384 )
2385 .unwrap();
2386 std::fs::write(
2387 build_src_dir.join("Deps.kt"),
2388 r#"
2389object Deps {
2390 object AndroidX {
2391 const val core = "androidx.core:core:1.15.0"
2392 }
2393
2394 object SoLoader {
2395 private const val version = "0.11.0"
2396 const val soloader = "com.facebook.soloader:soloader:$version"
2397 }
2398}
2399"#,
2400 )
2401 .unwrap();
2402 std::fs::write(
2403 build_src_dir.join("TestDeps.kt"),
2404 r#"
2405object TestDeps {
2406 const val junit = "junit:junit:4.13.2"
2407}
2408"#,
2409 )
2410 .unwrap();
2411
2412 let build_gradle = temp_dir.path().join("build.gradle");
2413 std::fs::write(
2414 &build_gradle,
2415 r#"
2416buildscript {
2417 dependencies {
2418 classpath GradleDeps.Kotlin.gradlePlugin
2419 }
2420}
2421
2422dependencies {
2423 implementation Deps.AndroidX.core
2424 implementation Deps.SoLoader.soloader
2425 implementation project(':fbcore')
2426 testImplementation(TestDeps.junit) {
2427 because 'exercise parenthesized symbolic refs'
2428 }
2429}
2430"#,
2431 )
2432 .unwrap();
2433
2434 let package_data = GradleParser::extract_first_package(&build_gradle);
2435
2436 assert_eq!(package_data.dependencies.len(), 5);
2437 assert!(package_data.dependencies.iter().any(|dependency| {
2438 dependency.purl.as_deref()
2439 == Some("pkg:maven/org.jetbrains.kotlin/kotlin-gradle-plugin@2.0.0")
2440 && dependency.scope.as_deref() == Some("classpath")
2441 }));
2442 assert!(package_data.dependencies.iter().any(|dependency| {
2443 dependency.purl.as_deref() == Some("pkg:maven/androidx.core/core@1.15.0")
2444 && dependency.scope.as_deref() == Some("implementation")
2445 }));
2446 assert!(package_data.dependencies.iter().any(|dependency| {
2447 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.soloader/soloader@0.11.0")
2448 && dependency.scope.as_deref() == Some("implementation")
2449 }));
2450 assert!(package_data.dependencies.iter().any(|dependency| {
2451 dependency.purl.as_deref() == Some("pkg:maven/fbcore")
2452 && dependency.scope.as_deref() == Some("implementation")
2453 }));
2454 assert!(package_data.dependencies.iter().any(|dependency| {
2455 dependency.purl.as_deref() == Some("pkg:maven/junit/junit@4.13.2")
2456 && dependency.scope.as_deref() == Some("testImplementation")
2457 && dependency.is_runtime == Some(false)
2458 && dependency.is_optional == Some(true)
2459 }));
2460 }
2461
2462 #[test]
2463 fn test_gradle_properties_and_local_assignments_resolve_interpolation() {
2464 let temp_dir = tempdir().unwrap();
2465 std::fs::write(
2466 temp_dir.path().join("gradle.properties"),
2467 "ktorVersion=2.3.10\nkotlinVersion=2.0.0\n",
2468 )
2469 .unwrap();
2470 let build_gradle = temp_dir.path().join("build.gradle.kts");
2471 std::fs::write(
2472 &build_gradle,
2473 r#"
2474val ktorVersion: String by project
2475val kotlinVersion = "2.1.0"
2476
2477dependencies {
2478 implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
2479 testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
2480}
2481"#,
2482 )
2483 .unwrap();
2484
2485 let package_data = GradleParser::extract_first_package(&build_gradle);
2486 assert_eq!(package_data.dependencies.len(), 2);
2487 assert!(package_data.dependencies.iter().any(|dependency| {
2488 dependency.purl.as_deref() == Some("pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@2.1.0")
2489 && dependency.extracted_requirement.as_deref() == Some("2.1.0")
2490 && dependency.scope.as_deref() == Some("implementation")
2491 }));
2492 assert!(package_data.dependencies.iter().any(|dependency| {
2493 dependency.purl.as_deref() == Some("pkg:maven/io.ktor/ktor-server-test-host@2.3.10")
2494 && dependency.extracted_requirement.as_deref() == Some("2.3.10")
2495 && dependency.scope.as_deref() == Some("testImplementation")
2496 }));
2497 }
2498
2499 #[test]
2500 fn test_conditional_dependencies_inside_if_blocks_are_extracted() {
2501 let temp_dir = tempdir().unwrap();
2502 let build_gradle = temp_dir.path().join("build.gradle");
2503 std::fs::write(
2504 &build_gradle,
2505 r#"
2506def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
2507
2508dependencies {
2509 implementation("com.facebook.react:react-android")
2510
2511 if (hermesEnabled.toBoolean()) {
2512 implementation("com.facebook.react:hermes-android")
2513 } else {
2514 implementation jscFlavor
2515 }
2516}
2517"#,
2518 )
2519 .unwrap();
2520
2521 let package_data = GradleParser::extract_first_package(&build_gradle);
2522
2523 assert!(package_data.dependencies.iter().any(|dependency| {
2524 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.react/react-android")
2525 && dependency.scope.as_deref() == Some("implementation")
2526 }));
2527 assert!(package_data.dependencies.iter().any(|dependency| {
2528 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.react/hermes-android")
2529 && dependency.scope.as_deref() == Some("implementation")
2530 }));
2531 }
2532
2533 #[test]
2534 fn test_compile_only_is_not_runtime() {
2535 let content = r#"
2536dependencies {
2537 compileOnly 'org.antlr:antlr:2.7.7'
2538 compileOnlyApi 'com.example:annotations:1.0.0'
2539 testCompileOnly 'junit:junit:4.13'
2540}
2541"#;
2542 let tokens = lex(content);
2543 let deps = extract_dependencies(&tokens);
2544
2545 assert_eq!(deps.len(), 3);
2546 assert_eq!(deps[0].scope, Some("compileOnly".to_string()));
2547 assert_eq!(deps[0].is_runtime, Some(false));
2548 assert_eq!(deps[0].is_optional, Some(false));
2549
2550 assert_eq!(deps[1].scope, Some("compileOnlyApi".to_string()));
2551 assert_eq!(deps[1].is_runtime, Some(false));
2552 assert_eq!(deps[1].is_optional, Some(false));
2553
2554 assert_eq!(deps[2].scope, Some("testCompileOnly".to_string()));
2555 assert_eq!(deps[2].is_runtime, Some(false));
2556 assert_eq!(deps[2].is_optional, Some(true));
2557 }
2558
2559 #[test]
2560 fn test_version_catalog_alias_resolution_from_libs_versions_toml() {
2561 let temp_dir = tempdir().unwrap();
2562 let gradle_dir = temp_dir.path().join("gradle");
2563 std::fs::create_dir_all(&gradle_dir).unwrap();
2564
2565 std::fs::write(
2566 gradle_dir.join("libs.versions.toml"),
2567 r#"
2568[versions]
2569androidxAppcompat = "1.7.0"
2570
2571[libraries]
2572androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2573guardianproject-panic = { group = "info.guardianproject", name = "panic", version = "1.0.0" }
2574"#,
2575 )
2576 .unwrap();
2577
2578 let build_gradle = temp_dir.path().join("build.gradle");
2579 std::fs::write(
2580 &build_gradle,
2581 r#"
2582dependencies {
2583 implementation libs.androidx.appcompat
2584 fullImplementation libs.guardianproject.panic
2585}
2586"#,
2587 )
2588 .unwrap();
2589
2590 let package_data = GradleParser::extract_first_package(&build_gradle);
2591
2592 assert_eq!(package_data.dependencies.len(), 2);
2593 assert_eq!(
2594 package_data.dependencies[0].purl,
2595 Some("pkg:maven/androidx.appcompat/appcompat@1.7.0".to_string())
2596 );
2597 assert_eq!(
2598 package_data.dependencies[0].scope,
2599 Some("implementation".to_string())
2600 );
2601 assert_eq!(
2602 package_data.dependencies[1].purl,
2603 Some("pkg:maven/info.guardianproject/panic@1.0.0".to_string())
2604 );
2605 assert_eq!(
2606 package_data.dependencies[1].scope,
2607 Some("fullImplementation".to_string())
2608 );
2609 }
2610
2611 #[test]
2612 fn test_extract_gradle_license_metadata_from_pom_block() {
2613 let content = r#"
2614plugins {
2615 id 'java-library'
2616 id 'maven'
2617}
2618
2619dependencies {
2620 api 'org.apache.commons:commons-text:1.1'
2621}
2622
2623configure(install.repositories.mavenInstaller) {
2624 pom.project {
2625 licenses {
2626 license {
2627 name 'The Apache License, Version 2.0'
2628 url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
2629 }
2630 }
2631 }
2632}
2633"#;
2634
2635 let temp_dir = tempdir().unwrap();
2636 let build_gradle = temp_dir.path().join("build.gradle");
2637 std::fs::write(&build_gradle, content).unwrap();
2638
2639 let package_data = GradleParser::extract_first_package(&build_gradle);
2640
2641 assert_eq!(
2642 package_data.extracted_license_statement,
2643 Some(
2644 "- license:\n name: The Apache License, Version 2.0\n url: http://www.apache.org/licenses/LICENSE-2.0.txt\n"
2645 .to_string()
2646 )
2647 );
2648 assert_eq!(
2649 package_data.declared_license_expression_spdx,
2650 Some("Apache-2.0".to_string())
2651 );
2652 }
2653
2654 #[test]
2655 fn test_parse_gradle_version_catalog_helper() {
2656 let temp_dir = tempdir().unwrap();
2657 let catalog_path = temp_dir.path().join("libs.versions.toml");
2658 std::fs::write(
2659 &catalog_path,
2660 r#"
2661[versions]
2662androidxAppcompat = "1.7.0"
2663
2664[libraries]
2665androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2666"#,
2667 )
2668 .unwrap();
2669
2670 let entries = parse_gradle_version_catalog(&catalog_path).unwrap();
2671 let entry = entries.get("androidx.appcompat").unwrap();
2672
2673 assert_eq!(entry.namespace, "androidx.appcompat");
2674 assert_eq!(entry.name, "appcompat");
2675 assert_eq!(entry.version.as_deref(), Some("1.7.0"));
2676 }
2677
2678 #[test]
2679 fn test_string_interpolation() {
2680 let content = r#"
2681dependencies {
2682 compile "com.amazonaws:aws-java-sdk-core:${awsVer}"
2683}
2684"#;
2685 let tokens = lex(content);
2686 let deps = extract_dependencies(&tokens);
2687 assert_eq!(deps.len(), 1);
2688 assert_eq!(deps[0].extracted_requirement, Some("${awsVer}".to_string()));
2689 assert_eq!(
2690 deps[0].purl,
2691 Some("pkg:maven/com.amazonaws/aws-java-sdk-core@%24%7BawsVer%7D".to_string())
2692 );
2693 }
2694
2695 #[test]
2696 fn test_multi_value_string_notation() {
2697 let content = r#"
2698dependencies {
2699 runtimeOnly 'org.springframework:spring-core:2.5',
2700 'org.springframework:spring-aop:2.5'
2701}
2702"#;
2703 let tokens = lex(content);
2704 let deps = extract_dependencies(&tokens);
2705 assert_eq!(deps.len(), 2);
2706 assert_eq!(deps[0].scope, Some("".to_string()));
2707 assert_eq!(deps[1].scope, Some("".to_string()));
2708 }
2709
2710 #[test]
2711 fn test_kotlin_quoted_scope_string_dependency_extracted() {
2712 let content = r#"
2713dependencies {
2714 "js"("jquery:jquery:3.2.1@js")
2715}
2716"#;
2717 let tokens = lex(content);
2718 let deps = extract_dependencies(&tokens);
2719 assert_eq!(deps.len(), 1);
2720 assert_eq!(deps[0].scope, Some("js".to_string()));
2721 assert_eq!(
2722 deps[0].purl,
2723 Some("pkg:maven/jquery/jquery@3.2.1%40js".to_string())
2724 );
2725 }
2726
2727 #[test]
2728 fn test_kotlin_quoted_scope_project_reference_extracted() {
2729 let content = r#"
2730subprojects {
2731 dependencies {
2732 "testImplementation"(project(":utils:test-utils"))
2733 }
2734}
2735"#;
2736 let tokens = lex(content);
2737 let deps = extract_dependencies(&tokens);
2738 assert_eq!(deps.len(), 1);
2739 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2740 assert_eq!(deps[0].purl, Some("pkg:maven/utils/test-utils".to_string()));
2741 assert_eq!(deps[0].is_runtime, Some(false));
2742 assert_eq!(deps[0].is_optional, Some(true));
2743 assert_eq!(
2744 deps[0]
2745 .extra_data
2746 .as_ref()
2747 .and_then(|data| data.get("project_path"))
2748 .and_then(|value| value.as_str()),
2749 Some("utils:test-utils")
2750 );
2751 }
2752
2753 #[test]
2754 fn test_kotlin_quoted_scope_string_dependency_with_closure_extracted() {
2755 let content = r#"
2756dependencies {
2757 "implementation"("com.badlogicgames.gdx:gdx-tools:1.14.0") {
2758 exclude("com.badlogicgames.gdx", "gdx-backend-lwjgl")
2759 }
2760}
2761"#;
2762 let tokens = lex(content);
2763 let deps = extract_dependencies(&tokens);
2764 assert_eq!(deps.len(), 1);
2765 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2766 assert_eq!(
2767 deps[0].purl,
2768 Some("pkg:maven/com.badlogicgames.gdx/gdx-tools@1.14.0".to_string())
2769 );
2770 }
2771
2772 #[test]
2773 fn test_closure_after_dependency() {
2774 let content = r#"
2775dependencies {
2776 runtimeOnly('org.hibernate:hibernate:3.0.5') {
2777 transitive = true
2778 }
2779}
2780"#;
2781 let tokens = lex(content);
2782 let deps = extract_dependencies(&tokens);
2783 assert_eq!(deps.len(), 1);
2784 assert_eq!(
2785 deps[0].purl,
2786 Some("pkg:maven/org.hibernate/hibernate@3.0.5".to_string())
2787 );
2788 assert_eq!(deps[0].scope, Some("runtimeOnly".to_string()));
2789 }
2790}