1use std::path::Path;
25
26use crate::parser_warn as warn;
27use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
28
29const MAX_RECURSION_DEPTH: usize = 50;
30use packageurl::PackageUrl;
31use serde_json::json;
32
33use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
34use crate::parsers::PackageParser;
35
36use super::license_normalization::{
37 DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
38 normalize_spdx_expression,
39};
40
41pub struct GradleParser;
71
72impl PackageParser for GradleParser {
73 const PACKAGE_TYPE: PackageType = PackageType::Maven;
74
75 fn is_match(path: &Path) -> bool {
76 path.file_name().is_some_and(|name| {
77 let name_str = name.to_string_lossy();
78 name_str == "build.gradle" || name_str == "build.gradle.kts"
79 })
80 }
81
82 fn extract_packages(path: &Path) -> Vec<PackageData> {
83 let content = match read_file_to_string(path, None) {
84 Ok(c) => c,
85 Err(e) => {
86 warn!("Failed to read {:?}: {}", path, e);
87 return vec![default_package_data()];
88 }
89 };
90
91 let tokens = lex(&content);
92 let mut dependencies = extract_dependencies(&tokens);
93 resolve_gradle_version_catalog_aliases(path, &mut dependencies);
94 let (
95 extracted_license_statement,
96 declared_license_expression,
97 declared_license_expression_spdx,
98 license_detections,
99 ) = extract_gradle_license_metadata(&tokens);
100
101 vec![PackageData {
102 package_type: Some(Self::PACKAGE_TYPE),
103 namespace: None,
104 name: None,
105 version: None,
106 qualifiers: None,
107 subpath: None,
108 primary_language: None,
109 description: None,
110 release_date: None,
111 parties: Vec::new(),
112 keywords: Vec::new(),
113 homepage_url: None,
114 download_url: None,
115 size: None,
116 sha1: None,
117 md5: None,
118 sha256: None,
119 sha512: None,
120 bug_tracking_url: None,
121 code_view_url: None,
122 vcs_url: None,
123 copyright: None,
124 holder: None,
125 declared_license_expression,
126 declared_license_expression_spdx,
127 license_detections,
128 other_license_expression: None,
129 other_license_expression_spdx: None,
130 other_license_detections: Vec::new(),
131 extracted_license_statement,
132 notice_text: None,
133 source_packages: Vec::new(),
134 file_references: Vec::new(),
135 extra_data: None,
136 dependencies,
137 repository_homepage_url: None,
138 repository_download_url: None,
139 api_data_url: None,
140 datasource_id: Some(DatasourceId::BuildGradle),
141 purl: None,
142 is_private: false,
143 is_virtual: false,
144 }]
145 }
146}
147
148fn default_package_data() -> PackageData {
149 PackageData {
150 package_type: Some(GradleParser::PACKAGE_TYPE),
151 datasource_id: Some(DatasourceId::BuildGradle),
152 ..Default::default()
153 }
154}
155
156#[derive(Debug, Clone, PartialEq)]
161enum Tok {
162 Ident(String),
163 Str(String),
164 MalformedStr(String),
165 OpenParen,
166 CloseParen,
167 OpenBracket,
168 CloseBracket,
169 OpenBrace,
170 CloseBrace,
171 Colon,
172 Comma,
173 Equals,
174}
175
176fn lex(input: &str) -> Vec<Tok> {
177 let chars: Vec<char> = input.chars().collect();
178 let len = chars.len();
179 let mut i = 0;
180 let mut tokens = Vec::new();
181
182 while i < len {
183 if tokens.len() >= MAX_ITERATION_COUNT {
184 warn!(
185 "Lexer exceeded MAX_ITERATION_COUNT ({}) tokens, stopping",
186 MAX_ITERATION_COUNT
187 );
188 break;
189 }
190 let c = chars[i];
191
192 if c == '/' && i + 1 < len && chars[i + 1] == '/' {
193 while i < len && chars[i] != '\n' {
194 i += 1;
195 }
196 continue;
197 }
198
199 if c == '/' && i + 1 < len && chars[i + 1] == '*' {
200 i += 2;
201 while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
202 i += 1;
203 }
204 i += 2;
205 continue;
206 }
207
208 if c.is_whitespace() {
209 i += 1;
210 continue;
211 }
212
213 if c == '\'' {
214 i += 1;
215 let start = i;
216 while i < len && chars[i] != '\'' && chars[i] != '\n' {
217 i += 1;
218 }
219 let val: String = chars[start..i].iter().collect();
220 let val = truncate_field(val);
221 if i < len && chars[i] == '\'' {
222 tokens.push(Tok::Str(val));
223 i += 1;
224 } else {
225 tokens.push(Tok::MalformedStr(val));
226 }
227 continue;
228 }
229
230 if c == '"' {
231 i += 1;
232 let start = i;
233 while i < len && chars[i] != '"' && chars[i] != '\n' {
234 if chars[i] == '\\' && i + 1 < len {
235 i += 2;
236 } else {
237 i += 1;
238 }
239 }
240 let val: String = chars[start..i].iter().collect();
241 let val = truncate_field(val);
242 if i < len && chars[i] == '"' {
243 tokens.push(Tok::Str(val));
244 i += 1;
245 } else {
246 tokens.push(Tok::MalformedStr(val));
247 }
248 continue;
249 }
250
251 match c {
252 '(' => {
253 tokens.push(Tok::OpenParen);
254 i += 1;
255 }
256 ')' => {
257 tokens.push(Tok::CloseParen);
258 i += 1;
259 }
260 '[' => {
261 tokens.push(Tok::OpenBracket);
262 i += 1;
263 }
264 ']' => {
265 tokens.push(Tok::CloseBracket);
266 i += 1;
267 }
268 '{' => {
269 tokens.push(Tok::OpenBrace);
270 i += 1;
271 }
272 '}' => {
273 tokens.push(Tok::CloseBrace);
274 i += 1;
275 }
276 ':' => {
277 tokens.push(Tok::Colon);
278 i += 1;
279 }
280 ',' => {
281 tokens.push(Tok::Comma);
282 i += 1;
283 }
284 '=' => {
285 tokens.push(Tok::Equals);
286 i += 1;
287 }
288 _ if is_ident_start(c) => {
289 let start = i;
290 while i < len && is_ident_char(chars[i]) {
291 i += 1;
292 }
293 let val: String = chars[start..i].iter().collect();
294 tokens.push(Tok::Ident(truncate_field(val)));
295 }
296 _ => {
297 i += 1;
298 }
299 }
300 }
301
302 tokens
303}
304
305fn is_ident_start(c: char) -> bool {
306 c.is_ascii_alphanumeric() || c == '_' || c == '-'
307}
308
309fn is_ident_char(c: char) -> bool {
310 c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' || c == '$'
311}
312
313fn find_dependency_blocks(tokens: &[Tok]) -> Vec<Vec<Tok>> {
318 let mut blocks = Vec::new();
319 let mut i = 0;
320
321 while i < tokens.len() {
322 if let Tok::Ident(ref name) = tokens[i]
323 && name == "dependencies"
324 && i + 1 < tokens.len()
325 && tokens[i + 1] == Tok::OpenBrace
326 {
327 i += 2;
328 let mut depth = 1;
329 let start = i;
330 while i < tokens.len() && depth > 0 {
331 match &tokens[i] {
332 Tok::OpenBrace => {
333 depth += 1;
334 if depth > MAX_RECURSION_DEPTH {
335 warn!(
336 "Gradle parser: nesting depth exceeded {} in find_dependency_blocks",
337 MAX_RECURSION_DEPTH
338 );
339 break;
340 }
341 }
342 Tok::CloseBrace => depth -= 1,
343 _ => {}
344 }
345 if depth > 0 {
346 i += 1;
347 }
348 }
349 blocks.push(tokens[start..i].to_vec());
350 if i < tokens.len() {
351 i += 1;
352 }
353 continue;
354 }
355 i += 1;
356 }
357
358 blocks
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Hash)]
366struct RawDep {
367 namespace: String,
368 name: String,
369 version: String,
370 scope: String,
371 catalog_alias: Option<String>,
372 project_path: Option<String>,
373}
374
375fn extract_dependencies(tokens: &[Tok]) -> Vec<Dependency> {
376 let blocks = find_dependency_blocks(tokens);
377 let mut dependencies = Vec::new();
378
379 for block in blocks {
380 for rd in parse_block(&block).into_iter().take(MAX_ITERATION_COUNT) {
381 if rd.name.is_empty() {
382 continue;
383 }
384 if let Some(dep) = create_dependency(&rd) {
385 dependencies.push(dep);
386 }
387 }
388 }
389
390 dependencies
391}
392
393fn parse_block(tokens: &[Tok]) -> Vec<RawDep> {
394 let mut deps = Vec::new();
395 let mut i = 0;
396 let mut iterations = 0;
397
398 while i < tokens.len() {
399 iterations += 1;
400 if iterations > MAX_ITERATION_COUNT {
401 warn!(
402 "parse_block exceeded MAX_ITERATION_COUNT ({}) iterations, stopping",
403 MAX_ITERATION_COUNT
404 );
405 break;
406 }
407 if tokens[i] == Tok::OpenBrace {
409 let mut depth = 1;
410 i += 1;
411 while i < tokens.len() && depth > 0 {
412 match &tokens[i] {
413 Tok::OpenBrace => {
414 depth += 1;
415 if depth > MAX_RECURSION_DEPTH {
416 warn!(
417 "Gradle parser: nesting depth exceeded {} in parse_block",
418 MAX_RECURSION_DEPTH
419 );
420 break;
421 }
422 }
423 Tok::CloseBrace => depth -= 1,
424 _ => {}
425 }
426 i += 1;
427 }
428 continue;
429 }
430
431 if let Tok::Str(_) = &tokens[i]
432 && i + 1 < tokens.len()
433 && tokens[i + 1] == Tok::OpenParen
434 && let Some(end) = find_matching_paren(tokens, i + 1)
435 {
436 let inner = &tokens[i + 2..end];
437 if let Some(Tok::Ident(inner_fn)) = inner.first()
438 && inner_fn == "project"
439 && inner.len() > 1
440 && inner[1] == Tok::OpenParen
441 && let Some(project_end) = find_matching_paren(inner, 1)
442 {
443 let project_tokens = &inner[2..project_end];
444 if let Some(rd) = parse_project_ref(project_tokens) {
445 deps.push(rd);
446 }
447 i = end + 1;
448 continue;
449 }
450 }
451
452 let scope_name = match &tokens[i] {
453 Tok::Ident(name) => name.clone(),
454 _ => {
455 i += 1;
456 continue;
457 }
458 };
459
460 if is_skip_keyword(&scope_name) {
461 i += 1;
462 continue;
463 }
464
465 let next = i + 1;
466
467 if next < tokens.len() && tokens[next] == Tok::OpenParen {
469 let paren_end = find_matching_paren(tokens, next);
470 if let Some(end) = paren_end {
471 let inner = &tokens[next + 1..end];
472 parse_paren_content(&scope_name, inner, &mut deps);
473 i = end + 1;
474 continue;
475 }
476 }
477
478 if next < tokens.len()
480 && let Tok::Ident(ref label) = tokens[next]
481 && label == "group"
482 && next + 1 < tokens.len()
483 && tokens[next + 1] == Tok::Colon
484 && let Some((rd, consumed)) = parse_named_params(&scope_name, &tokens[next..])
485 {
486 deps.push(rd);
487 i = next + consumed;
488 continue;
489 }
490
491 if next < tokens.len()
493 && matches!(
494 tokens.get(next),
495 Some(Tok::Str(_)) | Some(Tok::MalformedStr(_))
496 )
497 {
498 let (val, is_malformed) = match &tokens[next] {
499 Tok::Str(val) => (val.as_str(), false),
500 Tok::MalformedStr(val) => (val.as_str(), true),
501 _ => unreachable!(),
502 };
503
504 if !val.contains(':') {
505 i = next + 1;
506 continue;
507 }
508
509 if val.chars().next().is_some_and(|c| c.is_whitespace()) {
510 break;
511 }
512
513 if next + 1 < tokens.len()
515 && tokens[next + 1] == Tok::Comma
516 && next + 2 < tokens.len()
517 && tokens[next + 2] == Tok::OpenBrace
518 {
519 i = next + 1;
520 continue;
521 }
522 let is_multi = i + 2 < tokens.len()
523 && tokens[next + 1] == Tok::Comma
524 && matches!(tokens.get(next + 2), Some(Tok::Str(_)));
525 let effective_scope = if is_multi { "" } else { &scope_name };
526 let rd = parse_colon_string(val, effective_scope);
527 deps.push(rd);
528 if is_malformed {
529 break;
530 }
531 i = next + 1;
532 while i < tokens.len() && tokens[i] == Tok::Comma {
533 i += 1;
534 if i < tokens.len()
535 && let Tok::Str(ref v2) = tokens[i]
536 && v2.contains(':')
537 {
538 deps.push(parse_colon_string(v2, ""));
539 i += 1;
540 continue;
541 }
542 break;
543 }
544 continue;
545 }
546
547 if next < tokens.len()
551 && let Tok::Ident(ref val) = tokens[next]
552 && val.contains('.')
553 && !val.starts_with("dependencies.")
554 && let Some(last_seg) = val.rsplit('.').next()
555 && !last_seg.is_empty()
556 {
557 deps.push(RawDep {
558 namespace: String::new(),
559 name: truncate_field(last_seg.to_string()),
560 version: String::new(),
561 scope: truncate_field(scope_name.clone()),
562 catalog_alias: val
563 .strip_prefix("libs.")
564 .map(|alias| truncate_field(alias.to_string())),
565 project_path: None,
566 });
567 i = next + 1;
568 continue;
569 }
570
571 if next < tokens.len()
573 && let Tok::Ident(ref name) = tokens[next]
574 && name == "project"
575 && next + 1 < tokens.len()
576 && tokens[next + 1] == Tok::OpenParen
577 && let Some(end) = find_matching_paren(tokens, next + 1)
578 {
579 let inner = &tokens[next + 2..end];
580 if let Some(rd) = parse_project_ref(inner) {
581 deps.push(rd);
582 }
583 i = end + 1;
584 continue;
585 }
586
587 i += 1;
588 }
589
590 deps
591}
592
593fn is_skip_keyword(name: &str) -> bool {
594 matches!(
595 name,
596 "plugins"
597 | "apply"
598 | "ext"
599 | "configurations"
600 | "repositories"
601 | "subprojects"
602 | "allprojects"
603 | "buildscript"
604 | "pluginManager"
605 | "publishing"
606 | "sourceSets"
607 | "tasks"
608 | "task"
609 )
610}
611
612fn parse_paren_content(scope: &str, tokens: &[Tok], deps: &mut Vec<RawDep>) {
613 if tokens.is_empty() {
614 return;
615 }
616
617 if tokens[0] == Tok::OpenBracket {
619 parse_bracket_maps(tokens, deps);
620 return;
621 }
622
623 if let Some(Tok::Ident(label)) = tokens.first()
625 && label == "group"
626 && tokens.len() > 1
627 && tokens[1] == Tok::Colon
628 {
629 if let Some((rd, _)) = parse_named_params("", tokens) {
630 deps.push(rd);
631 }
632 return;
633 }
634
635 if let Some(Tok::Ident(inner_fn)) = tokens.first()
637 && tokens.len() > 1
638 && tokens[1] == Tok::OpenParen
639 {
640 if inner_fn == "project" {
641 if let Some(end) = find_matching_paren(tokens, 1) {
642 let inner = &tokens[2..end];
643 if let Some(rd) = parse_project_ref(inner) {
644 deps.push(rd);
645 }
646 }
647 return;
648 }
649
650 if let Some(end) = find_matching_paren(tokens, 1) {
651 let inner = &tokens[2..end];
652 if let Some(Tok::Str(val)) = inner.first()
653 && val.contains(':')
654 {
655 deps.push(parse_colon_string(val, inner_fn));
656 return;
657 }
658 }
659 }
660
661 if let Some(Tok::Str(val)) = tokens.first()
663 && val.contains(':')
664 {
665 deps.push(parse_colon_string(val, scope));
666 }
667}
668
669fn parse_bracket_maps(tokens: &[Tok], deps: &mut Vec<RawDep>) {
670 let mut i = 0;
671 while i < tokens.len() {
672 if tokens[i] == Tok::OpenBracket
673 && let Some(end) = find_matching_bracket(tokens, i)
674 {
675 let map_tokens = &tokens[i + 1..end];
676 if let Some(rd) = parse_map_entries(map_tokens)
677 && !contains_equivalent_map_dep(deps, &rd)
678 {
679 deps.push(rd);
680 }
681 i = end + 1;
682 continue;
683 }
684 i += 1;
685 }
686}
687
688fn contains_equivalent_map_dep(existing: &[RawDep], candidate: &RawDep) -> bool {
689 existing.iter().any(|dep| {
690 dep.name == candidate.name
691 && dep.version == candidate.version
692 && dep.scope == candidate.scope
693 && (dep.namespace == candidate.namespace
694 || dep.namespace.is_empty()
695 || candidate.namespace.is_empty())
696 })
697}
698
699fn parse_map_entries(tokens: &[Tok]) -> Option<RawDep> {
700 let mut name = String::new();
701 let mut version = String::new();
702 let mut i = 0;
703
704 while i < tokens.len() {
705 if let Tok::Ident(ref label) = tokens[i]
706 && i + 2 < tokens.len()
707 && tokens[i + 1] == Tok::Colon
708 && let Tok::Str(ref val) = tokens[i + 2]
709 {
710 match label.as_str() {
711 "name" => name = truncate_field(val.clone()),
712 "version" => version = truncate_field(val.clone()),
713 _ => {}
714 }
715 i += 3;
716 if i < tokens.len() && tokens[i] == Tok::Comma {
717 i += 1;
718 }
719 continue;
720 }
721 i += 1;
722 }
723
724 if name.is_empty() {
725 return None;
726 }
727
728 Some(RawDep {
729 namespace: String::new(),
730 name,
731 version,
732 scope: String::new(),
733 catalog_alias: None,
734 project_path: None,
735 })
736}
737
738fn parse_named_params(scope: &str, tokens: &[Tok]) -> Option<(RawDep, usize)> {
739 let mut group = String::new();
740 let mut name = String::new();
741 let mut version = String::new();
742 let mut i = 0;
743
744 while i < tokens.len() {
745 if let Tok::Ident(ref label) = tokens[i]
746 && i + 2 < tokens.len()
747 && tokens[i + 1] == Tok::Colon
748 && let Tok::Str(ref val) = tokens[i + 2]
749 {
750 match label.as_str() {
751 "group" => group = truncate_field(val.clone()),
752 "name" => name = truncate_field(val.clone()),
753 "version" => version = truncate_field(val.clone()),
754 _ => {}
755 }
756 i += 3;
757 if i < tokens.len() && tokens[i] == Tok::Comma {
758 i += 1;
759 }
760 continue;
761 }
762 break;
763 }
764
765 if name.is_empty() {
766 return None;
767 }
768
769 Some((
770 RawDep {
771 namespace: group,
772 name,
773 version,
774 scope: scope.to_string(),
775 catalog_alias: None,
776 project_path: None,
777 },
778 i,
779 ))
780}
781
782fn parse_project_ref(tokens: &[Tok]) -> Option<RawDep> {
783 if let Some(Tok::Str(val)) = tokens.first() {
784 let module_name = val.trim_start_matches(':');
785 let mut segments = module_name
786 .split(':')
787 .filter(|segment| !segment.is_empty())
788 .collect::<Vec<_>>();
789 let name = segments.pop().unwrap_or(module_name);
790 if name.is_empty() {
791 return None;
792 }
793 return Some(RawDep {
794 namespace: if segments.is_empty() {
795 String::new()
796 } else {
797 truncate_field(segments.join("/"))
798 },
799 name: truncate_field(name.to_string()),
800 version: String::new(),
801 scope: "project".to_string(),
802 catalog_alias: None,
803 project_path: Some(truncate_field(module_name.to_string())),
804 });
805 }
806 None
807}
808
809fn parse_colon_string(val: &str, scope: &str) -> RawDep {
810 let parts: Vec<&str> = val.split(':').collect();
811 let (namespace, name, version) = match parts.len() {
812 n if n >= 4 => (
813 truncate_field(parts[0].to_string()),
814 truncate_field(parts[1].to_string()),
815 truncate_field(parts[2].to_string()),
816 ),
817 3 => (
818 truncate_field(parts[0].to_string()),
819 truncate_field(parts[1].to_string()),
820 truncate_field(parts[2].to_string()),
821 ),
822 2 => (
823 truncate_field(parts[0].to_string()),
824 truncate_field(parts[1].to_string()),
825 String::new(),
826 ),
827 _ => (
828 String::new(),
829 truncate_field(val.to_string()),
830 String::new(),
831 ),
832 };
833
834 RawDep {
835 namespace,
836 name,
837 version,
838 scope: truncate_field(scope.to_string()),
839 catalog_alias: None,
840 project_path: None,
841 }
842}
843
844fn find_matching_paren(tokens: &[Tok], start: usize) -> Option<usize> {
845 if tokens.get(start) != Some(&Tok::OpenParen) {
846 return None;
847 }
848 let mut depth = 1;
849 let mut i = start + 1;
850 while i < tokens.len() && depth > 0 {
851 match &tokens[i] {
852 Tok::OpenParen => {
853 depth += 1;
854 if depth > MAX_RECURSION_DEPTH {
855 warn!(
856 "Gradle parser: nesting depth exceeded {} in find_matching_paren",
857 MAX_RECURSION_DEPTH
858 );
859 break;
860 }
861 }
862 Tok::CloseParen => depth -= 1,
863 _ => {}
864 }
865 if depth == 0 {
866 return Some(i);
867 }
868 i += 1;
869 }
870 None
871}
872
873fn find_matching_bracket(tokens: &[Tok], start: usize) -> Option<usize> {
874 if tokens.get(start) != Some(&Tok::OpenBracket) {
875 return None;
876 }
877 let mut depth = 1;
878 let mut i = start + 1;
879 while i < tokens.len() && depth > 0 {
880 match &tokens[i] {
881 Tok::OpenBracket => {
882 depth += 1;
883 if depth > MAX_RECURSION_DEPTH {
884 warn!(
885 "Gradle parser: nesting depth exceeded {} in find_matching_bracket",
886 MAX_RECURSION_DEPTH
887 );
888 break;
889 }
890 }
891 Tok::CloseBracket => depth -= 1,
892 _ => {}
893 }
894 if depth == 0 {
895 return Some(i);
896 }
897 i += 1;
898 }
899 None
900}
901
902fn create_dependency(raw: &RawDep) -> Option<Dependency> {
907 let namespace = raw.namespace.as_str();
908 let name = raw.name.as_str();
909 let version = raw.version.as_str();
910 let scope = raw.scope.as_str();
911 if name.is_empty() {
912 return None;
913 }
914
915 let mut purl = PackageUrl::new("maven", name).ok()?;
916
917 if !namespace.is_empty() {
918 purl.with_namespace(namespace).ok()?;
919 }
920
921 if !version.is_empty() {
922 purl.with_version(version).ok()?;
923 }
924
925 let (is_runtime, is_optional) = classify_scope(scope);
926 let is_pinned = !version.is_empty();
927
928 let purl_string = truncate_field(purl.to_string().replace("$", "%24").replace('\'', "%27"));
929 let mut extra_data = std::collections::HashMap::new();
930 if let Some(alias) = &raw.catalog_alias {
931 extra_data.insert(
932 "catalog_alias".to_string(),
933 json!(truncate_field(alias.clone())),
934 );
935 }
936 if let Some(project_path) = &raw.project_path {
937 extra_data.insert(
938 "project_path".to_string(),
939 json!(truncate_field(project_path.clone())),
940 );
941 }
942
943 Some(Dependency {
944 purl: Some(purl_string),
945 extracted_requirement: Some(truncate_field(version.to_string())),
946 scope: Some(truncate_field(scope.to_string())),
947 is_runtime: Some(is_runtime),
948 is_optional: Some(is_optional),
949 is_pinned: Some(is_pinned),
950 is_direct: Some(true),
951 resolved_package: None,
952 extra_data: (!extra_data.is_empty()).then_some(extra_data),
953 })
954}
955
956fn classify_scope(scope: &str) -> (bool, bool) {
957 let scope_lower = scope.to_lowercase();
958
959 if scope_lower.contains("test") {
960 return (false, true);
961 }
962
963 if matches!(
964 scope_lower.as_str(),
965 "compileonly" | "compileonlyapi" | "annotationprocessor" | "kapt" | "ksp"
966 ) {
967 return (false, false);
968 }
969
970 (true, false)
971}
972
973#[derive(Debug, Clone)]
974struct GradleCatalogEntry {
975 namespace: String,
976 name: String,
977 version: Option<String>,
978}
979
980fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
981 let Some(catalog_path) = find_gradle_version_catalog(path) else {
982 return;
983 };
984 let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
985 return;
986 };
987
988 for dep in dependencies.iter_mut() {
989 let alias = dep
990 .extra_data
991 .as_ref()
992 .and_then(|data| data.get("catalog_alias"))
993 .and_then(|value| value.as_str());
994 let Some(alias) = alias else {
995 continue;
996 };
997 let Some(entry) = entries.get(alias) else {
998 continue;
999 };
1000
1001 let mut purl = PackageUrl::new("maven", &entry.name).ok();
1002 if let Some(ref mut purl) = purl {
1003 if !entry.namespace.is_empty() {
1004 let _ = purl.with_namespace(&entry.namespace);
1005 }
1006 if let Some(version) = &entry.version {
1007 let _ = purl.with_version(version);
1008 }
1009 }
1010
1011 dep.purl = purl.map(|p| truncate_field(p.to_string()));
1012 dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1013 dep.is_pinned = Some(entry.version.is_some());
1014 }
1015}
1016
1017fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1018 for ancestor in path.ancestors() {
1019 let nested = ancestor.join("gradle").join("libs.versions.toml");
1020 if nested.is_file() {
1021 return Some(nested);
1022 }
1023
1024 let sibling = ancestor.join("libs.versions.toml");
1025 if sibling.is_file() {
1026 return Some(sibling);
1027 }
1028 }
1029
1030 None
1031}
1032
1033fn parse_gradle_version_catalog(
1034 path: &Path,
1035) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1036 let content = read_file_to_string(path, None).ok()?;
1037 let mut section = "";
1038 let mut versions = std::collections::HashMap::new();
1039 let mut libraries = std::collections::HashMap::new();
1040
1041 for line in content.lines().take(MAX_ITERATION_COUNT) {
1042 let trimmed = line.split('#').next().unwrap_or("").trim();
1043 if trimmed.is_empty() {
1044 continue;
1045 }
1046
1047 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1048 section = trimmed.trim_matches(&['[', ']'][..]);
1049 continue;
1050 }
1051
1052 let Some((key, value)) = trimmed.split_once('=') else {
1053 continue;
1054 };
1055 let key = key.trim().to_string();
1056 let value = value.trim().to_string();
1057
1058 match section {
1059 "versions" => {
1060 versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1061 }
1062 "libraries" => {
1063 libraries.insert(key, value);
1064 }
1065 _ => {}
1066 }
1067 }
1068
1069 let mut result = std::collections::HashMap::new();
1070 for (alias, raw_value) in libraries.into_iter().take(MAX_ITERATION_COUNT) {
1071 let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1072 continue;
1073 };
1074 result.insert(truncate_field(alias.replace('-', ".")), entry);
1075 }
1076
1077 Some(result)
1078}
1079
1080fn parse_gradle_catalog_entry(
1081 raw_value: &str,
1082 versions: &std::collections::HashMap<String, String>,
1083) -> Option<GradleCatalogEntry> {
1084 if raw_value.starts_with('"') && raw_value.ends_with('"') {
1085 let notation = strip_quotes(raw_value);
1086 let mut parts = notation.split(':');
1087 let namespace = truncate_field(parts.next()?.to_string());
1088 let name = truncate_field(parts.next()?.to_string());
1089 let version = parts.next().map(|v| truncate_field(v.to_string()));
1090 return Some(GradleCatalogEntry {
1091 namespace,
1092 name,
1093 version,
1094 });
1095 }
1096
1097 if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1098 return None;
1099 }
1100
1101 let inner = &raw_value[1..raw_value.len() - 1];
1102 let mut fields = std::collections::HashMap::new();
1103 for pair in inner.split(',').take(MAX_ITERATION_COUNT) {
1104 let Some((key, value)) = pair.split_once('=') else {
1105 continue;
1106 };
1107 fields.insert(
1108 truncate_field(key.trim().to_string()),
1109 truncate_field(strip_quotes(value.trim()).to_string()),
1110 );
1111 }
1112
1113 let (namespace, name) = if let Some(module) = fields.get("module") {
1114 let (group, artifact) = module.split_once(':')?;
1115 (
1116 truncate_field(group.to_string()),
1117 truncate_field(artifact.to_string()),
1118 )
1119 } else {
1120 (
1121 truncate_field(fields.get("group")?.to_string()),
1122 truncate_field(fields.get("name")?.to_string()),
1123 )
1124 };
1125
1126 let version = if let Some(version) = fields.get("version") {
1127 Some(truncate_field(version.to_string()))
1128 } else if let Some(version_ref) = fields.get("version.ref") {
1129 versions.get(version_ref).cloned().map(truncate_field)
1130 } else {
1131 None
1132 };
1133
1134 Some(GradleCatalogEntry {
1135 namespace,
1136 name,
1137 version,
1138 })
1139}
1140
1141fn strip_quotes(value: &str) -> &str {
1142 value
1143 .strip_prefix('"')
1144 .and_then(|v| v.strip_suffix('"'))
1145 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1146 .unwrap_or(value)
1147}
1148
1149fn extract_gradle_license_metadata(
1150 tokens: &[Tok],
1151) -> (
1152 Option<String>,
1153 Option<String>,
1154 Option<String>,
1155 Vec<crate::models::LicenseDetection>,
1156) {
1157 let mut i = 0;
1158 while i < tokens.len() {
1159 if let Tok::Ident(name) = &tokens[i]
1160 && name == "licenses"
1161 && i + 1 < tokens.len()
1162 && tokens[i + 1] == Tok::OpenBrace
1163 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1164 {
1165 let inner = &tokens[i + 2..block_end];
1166 if let Some((license_name, license_url)) = parse_license_block(inner) {
1167 let extracted =
1168 format_gradle_license_statement(&license_name, license_url.as_deref());
1169 let declared_candidate =
1170 derive_gradle_license_expression(&license_name, license_url.as_deref());
1171 if let Some(declared_candidate) = declared_candidate
1172 && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1173 {
1174 let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1175 let (declared, declared_spdx, detections) = build_declared_license_data(
1176 normalized,
1177 DeclaredLicenseMatchMetadata::single_line(matched_text),
1178 );
1179 return (
1180 extracted.map(truncate_field),
1181 declared.map(truncate_field),
1182 declared_spdx.map(truncate_field),
1183 detections,
1184 );
1185 }
1186
1187 return (
1188 extracted.map(truncate_field),
1189 None,
1190 None,
1191 empty_declared_license_data().2,
1192 );
1193 }
1194 i = block_end + 1;
1195 continue;
1196 }
1197 i += 1;
1198 }
1199
1200 (None, None, None, Vec::new())
1201}
1202
1203fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1204 let mut i = 0;
1205 while i < tokens.len() {
1206 if let Tok::Ident(name) = &tokens[i]
1207 && name == "license"
1208 && i + 1 < tokens.len()
1209 && tokens[i + 1] == Tok::OpenBrace
1210 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1211 {
1212 let mut license_name = None;
1213 let mut license_url = None;
1214 let block = &tokens[i + 2..block_end];
1215 let mut j = 0;
1216 while j < block.len() {
1217 if let Tok::Ident(label) = &block[j] {
1218 let normalized = label.strip_suffix(".set").unwrap_or(label);
1219 if (normalized == "name" || normalized == "url")
1220 && let Some(value) = next_string_literal(block, j + 1)
1221 {
1222 if normalized == "name" {
1223 license_name = Some(value);
1224 } else {
1225 license_url = Some(value);
1226 }
1227 }
1228 }
1229 j += 1;
1230 }
1231
1232 return license_name.map(|name| (name, license_url));
1233 }
1234 i += 1;
1235 }
1236 None
1237}
1238
1239fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1240 for token in tokens.iter().skip(start) {
1241 match token {
1242 Tok::Str(value) => return Some(truncate_field(value.clone())),
1243 Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1244 Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1245 _ => break,
1246 }
1247 }
1248 None
1249}
1250
1251fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1252 if tokens.get(start) != Some(&Tok::OpenBrace) {
1253 return None;
1254 }
1255 let mut depth = 1;
1256 let mut i = start + 1;
1257 while i < tokens.len() && depth > 0 {
1258 match &tokens[i] {
1259 Tok::OpenBrace => {
1260 depth += 1;
1261 if depth > MAX_RECURSION_DEPTH {
1262 warn!(
1263 "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1264 MAX_RECURSION_DEPTH
1265 );
1266 break;
1267 }
1268 }
1269 Tok::CloseBrace => depth -= 1,
1270 _ => {}
1271 }
1272 if depth == 0 {
1273 return Some(i);
1274 }
1275 i += 1;
1276 }
1277 None
1278}
1279
1280fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
1281 let mut output = format!("- license:\n name: {name}\n");
1282 if let Some(url) = url {
1283 output.push_str(&format!(" url: {url}\n"));
1284 }
1285 Some(truncate_field(output))
1286}
1287
1288fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
1289 let trimmed = name.trim();
1290 let candidates = [trimmed, url.unwrap_or("")];
1291
1292 for candidate in candidates {
1293 let lower = candidate.to_ascii_lowercase();
1294 if trimmed == "Apache-2.0"
1295 || lower.contains("apache-2.0")
1296 || lower.contains("apache license, version 2.0")
1297 || lower.contains("apache.org/licenses/license-2.0")
1298 {
1299 return Some(truncate_field("Apache-2.0".to_string()));
1300 }
1301 if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
1302 return Some(truncate_field("MIT".to_string()));
1303 }
1304 if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
1305 return Some(truncate_field(trimmed.to_string()));
1306 }
1307 }
1308
1309 None
1310}
1311
1312crate::register_parser!(
1313 "Gradle build script",
1314 &["**/build.gradle", "**/build.gradle.kts"],
1315 "maven",
1316 "Java",
1317 Some("https://gradle.org/"),
1318);
1319
1320#[cfg(test)]
1321mod tests {
1322 use super::*;
1323 use tempfile::tempdir;
1324
1325 #[test]
1326 fn test_is_match() {
1327 assert!(GradleParser::is_match(Path::new("build.gradle")));
1328 assert!(GradleParser::is_match(Path::new("build.gradle.kts")));
1329 assert!(GradleParser::is_match(Path::new("project/build.gradle")));
1330 assert!(!GradleParser::is_match(Path::new("build.xml")));
1331 assert!(!GradleParser::is_match(Path::new("settings.gradle")));
1332 }
1333
1334 #[test]
1335 fn test_extract_simple_dependencies() {
1336 let content = r#"
1337dependencies {
1338 compile 'org.apache.commons:commons-text:1.1'
1339 testCompile 'junit:junit:4.12'
1340}
1341"#;
1342 let tokens = lex(content);
1343 let deps = extract_dependencies(&tokens);
1344 assert_eq!(deps.len(), 2);
1345
1346 let dep1 = &deps[0];
1347 assert_eq!(
1348 dep1.purl,
1349 Some("pkg:maven/org.apache.commons/commons-text@1.1".to_string())
1350 );
1351 assert_eq!(dep1.scope, Some("compile".to_string()));
1352 assert_eq!(dep1.is_runtime, Some(true));
1353 assert_eq!(dep1.is_pinned, Some(true));
1354
1355 let dep2 = &deps[1];
1356 assert_eq!(dep2.purl, Some("pkg:maven/junit/junit@4.12".to_string()));
1357 assert_eq!(dep2.scope, Some("testCompile".to_string()));
1358 assert_eq!(dep2.is_runtime, Some(false));
1359 assert_eq!(dep2.is_optional, Some(true));
1360 }
1361
1362 #[test]
1363 fn test_extract_parens_notation() {
1364 let content = r#"
1365dependencies {
1366 implementation("com.example:library:1.0.0")
1367 testImplementation("junit:junit:4.13")
1368}
1369"#;
1370 let tokens = lex(content);
1371 let deps = extract_dependencies(&tokens);
1372 assert_eq!(deps.len(), 2);
1373 assert_eq!(
1374 deps[0].purl,
1375 Some("pkg:maven/com.example/library@1.0.0".to_string())
1376 );
1377 }
1378
1379 #[test]
1380 fn test_extract_named_parameters() {
1381 let content = r#"
1382dependencies {
1383 api group: 'com.google.guava', name: 'guava', version: '30.1-jre'
1384}
1385"#;
1386 let tokens = lex(content);
1387 let deps = extract_dependencies(&tokens);
1388 assert_eq!(deps.len(), 1);
1389 assert_eq!(
1390 deps[0].purl,
1391 Some("pkg:maven/com.google.guava/guava@30.1-jre".to_string())
1392 );
1393 assert_eq!(deps[0].scope, Some("api".to_string()));
1394 }
1395
1396 #[test]
1397 fn test_multiple_dependency_blocks_all_parsed() {
1398 let content = r#"
1399dependencies {
1400 implementation 'org.scala-lang:scala-library:2.11.12'
1401}
1402
1403dependencies {
1404 implementation 'commons-collections:commons-collections:3.2.2'
1405 testImplementation 'junit:junit:4.13'
1406}
1407"#;
1408 let tokens = lex(content);
1409 let deps = extract_dependencies(&tokens);
1410 assert_eq!(deps.len(), 3);
1411 assert_eq!(
1412 deps[0].purl,
1413 Some("pkg:maven/org.scala-lang/scala-library@2.11.12".to_string())
1414 );
1415 assert_eq!(
1416 deps[1].purl,
1417 Some("pkg:maven/commons-collections/commons-collections@3.2.2".to_string())
1418 );
1419 assert_eq!(deps[2].purl, Some("pkg:maven/junit/junit@4.13".to_string()));
1420 assert_eq!(deps[2].scope, Some("testImplementation".to_string()));
1421 }
1422
1423 #[test]
1424 fn test_nested_dependency_blocks_all_parsed() {
1425 let content = r#"
1426buildscript {
1427 dependencies {
1428 classpath("org.eclipse.jgit:org.eclipse.jgit:$jgitVersion")
1429 }
1430}
1431
1432subprojects {
1433 dependencies {
1434 implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinPluginVersion")
1435 }
1436}
1437"#;
1438 let tokens = lex(content);
1439 let deps = extract_dependencies(&tokens);
1440
1441 assert_eq!(deps.len(), 2);
1442 assert_eq!(
1443 deps[0].purl,
1444 Some("pkg:maven/org.eclipse.jgit/org.eclipse.jgit@%24jgitVersion".to_string())
1445 );
1446 assert_eq!(deps[0].scope, Some("classpath".to_string()));
1447 assert_eq!(
1448 deps[1].purl,
1449 Some(
1450 "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib-jdk8@%24kotlinPluginVersion"
1451 .to_string()
1452 )
1453 );
1454 assert_eq!(deps[1].scope, Some("implementation".to_string()));
1455 }
1456
1457 #[test]
1458 fn test_no_version() {
1459 let content = r#"
1460dependencies {
1461 compile 'org.example:library'
1462}
1463"#;
1464 let tokens = lex(content);
1465 let deps = extract_dependencies(&tokens);
1466 assert_eq!(deps.len(), 1);
1467 assert_eq!(deps[0].is_pinned, Some(false));
1468 assert_eq!(deps[0].extracted_requirement, Some("".to_string()));
1469 }
1470
1471 #[test]
1472 fn test_nested_function_calls() {
1473 let content = r#"
1474dependencies {
1475 implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.12.2"))
1476 testImplementation(platform("org.junit:junit-bom:5.7.2"))
1477}
1478"#;
1479 let tokens = lex(content);
1480 let deps = extract_dependencies(&tokens);
1481 assert_eq!(deps.len(), 2);
1482 assert_eq!(
1483 deps[0].purl,
1484 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2".to_string())
1485 );
1486 assert_eq!(deps[0].scope, Some("enforcedPlatform".to_string()));
1487 assert_eq!(deps[1].scope, Some("platform".to_string()));
1488 }
1489
1490 #[test]
1491 fn test_map_format() {
1492 let content = r#"
1493dependencies {
1494 runtimeOnly(
1495 [group: 'org.jacoco', name: 'org.jacoco.ant', version: '0.7.4.201502262128'],
1496 [group: 'org.jacoco', name: 'org.jacoco.agent', version: '0.7.4.201502262128']
1497 )
1498}
1499"#;
1500 let tokens = lex(content);
1501 let deps = extract_dependencies(&tokens);
1502 assert_eq!(deps.len(), 2);
1503 assert_eq!(deps[0].scope, Some("".to_string()));
1504 assert_eq!(
1505 deps[0].purl,
1506 Some("pkg:maven/org.jacoco.ant@0.7.4.201502262128".to_string())
1507 );
1508 }
1509
1510 #[test]
1511 fn test_bracket_map_dedupes_exact_string_overlap() {
1512 let content = r#"
1513dependencies {
1514 runtimeOnly 'org.springframework:spring-core:2.5',
1515 'org.springframework:spring-aop:2.5'
1516 runtimeOnly(
1517 [group: 'org.springframework', name: 'spring-core', version: '2.5'],
1518 [group: 'org.springframework', name: 'spring-aop', version: '2.5']
1519 )
1520}
1521"#;
1522
1523 let tokens = lex(content);
1524 let deps = extract_dependencies(&tokens);
1525 assert_eq!(deps.len(), 2);
1526 assert_eq!(
1527 deps[0].purl,
1528 Some("pkg:maven/org.springframework/spring-core@2.5".to_string())
1529 );
1530 assert_eq!(
1531 deps[1].purl,
1532 Some("pkg:maven/org.springframework/spring-aop@2.5".to_string())
1533 );
1534 }
1535
1536 #[test]
1537 fn test_malformed_string_stops_cascading_false_positives() {
1538 let content = r#"
1539dependencies {
1540 implementation "com.fasterxml.jackson:jackson-bom:2.12.2'
1541 implementation" com.fasterxml.jackson.core:jackson-core"
1542 testImplementation 'org.junit:junit-bom:5.7.2'"
1543 testImplementation "org.junit.platform:junit-platform-commons"
1544}
1545"#;
1546
1547 let tokens = lex(content);
1548 let deps = extract_dependencies(&tokens);
1549 assert_eq!(deps.len(), 1);
1550 assert_eq!(
1551 deps[0].purl,
1552 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2%27".to_string())
1553 );
1554 }
1555
1556 #[test]
1557 fn test_project_references() {
1558 let content = r#"
1559dependencies {
1560 implementation(project(":documentation"))
1561 implementation(project(":basics"))
1562}
1563"#;
1564 let tokens = lex(content);
1565 let deps = extract_dependencies(&tokens);
1566 assert_eq!(deps.len(), 2);
1567 assert_eq!(deps[0].scope, Some("project".to_string()));
1568 assert_eq!(deps[0].purl, Some("pkg:maven/documentation".to_string()));
1569 assert_eq!(deps[1].purl, Some("pkg:maven/basics".to_string()));
1570 }
1571
1572 #[test]
1573 fn test_nested_project_references_preserve_parent_path() {
1574 let content = r#"
1575dependencies {
1576 implementation(project(":libs:download"))
1577 implementation(project(":libs:index"))
1578}
1579"#;
1580 let tokens = lex(content);
1581 let deps = extract_dependencies(&tokens);
1582
1583 assert_eq!(deps.len(), 2);
1584 assert_eq!(deps[0].purl, Some("pkg:maven/libs/download".to_string()));
1585 assert_eq!(deps[0].scope, Some("project".to_string()));
1586 assert_eq!(deps[1].purl, Some("pkg:maven/libs/index".to_string()));
1587 }
1588
1589 #[test]
1590 fn test_compile_only_is_not_runtime() {
1591 let content = r#"
1592dependencies {
1593 compileOnly 'org.antlr:antlr:2.7.7'
1594 compileOnlyApi 'com.example:annotations:1.0.0'
1595 testCompileOnly 'junit:junit:4.13'
1596}
1597"#;
1598 let tokens = lex(content);
1599 let deps = extract_dependencies(&tokens);
1600
1601 assert_eq!(deps.len(), 3);
1602 assert_eq!(deps[0].scope, Some("compileOnly".to_string()));
1603 assert_eq!(deps[0].is_runtime, Some(false));
1604 assert_eq!(deps[0].is_optional, Some(false));
1605
1606 assert_eq!(deps[1].scope, Some("compileOnlyApi".to_string()));
1607 assert_eq!(deps[1].is_runtime, Some(false));
1608 assert_eq!(deps[1].is_optional, Some(false));
1609
1610 assert_eq!(deps[2].scope, Some("testCompileOnly".to_string()));
1611 assert_eq!(deps[2].is_runtime, Some(false));
1612 assert_eq!(deps[2].is_optional, Some(true));
1613 }
1614
1615 #[test]
1616 fn test_version_catalog_alias_resolution_from_libs_versions_toml() {
1617 let temp_dir = tempdir().unwrap();
1618 let gradle_dir = temp_dir.path().join("gradle");
1619 std::fs::create_dir_all(&gradle_dir).unwrap();
1620
1621 std::fs::write(
1622 gradle_dir.join("libs.versions.toml"),
1623 r#"
1624[versions]
1625androidxAppcompat = "1.7.0"
1626
1627[libraries]
1628androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
1629guardianproject-panic = { group = "info.guardianproject", name = "panic", version = "1.0.0" }
1630"#,
1631 )
1632 .unwrap();
1633
1634 let build_gradle = temp_dir.path().join("build.gradle");
1635 std::fs::write(
1636 &build_gradle,
1637 r#"
1638dependencies {
1639 implementation libs.androidx.appcompat
1640 fullImplementation libs.guardianproject.panic
1641}
1642"#,
1643 )
1644 .unwrap();
1645
1646 let package_data = GradleParser::extract_first_package(&build_gradle);
1647
1648 assert_eq!(package_data.dependencies.len(), 2);
1649 assert_eq!(
1650 package_data.dependencies[0].purl,
1651 Some("pkg:maven/androidx.appcompat/appcompat@1.7.0".to_string())
1652 );
1653 assert_eq!(
1654 package_data.dependencies[0].scope,
1655 Some("implementation".to_string())
1656 );
1657 assert_eq!(
1658 package_data.dependencies[1].purl,
1659 Some("pkg:maven/info.guardianproject/panic@1.0.0".to_string())
1660 );
1661 assert_eq!(
1662 package_data.dependencies[1].scope,
1663 Some("fullImplementation".to_string())
1664 );
1665 }
1666
1667 #[test]
1668 fn test_extract_gradle_license_metadata_from_pom_block() {
1669 let content = r#"
1670plugins {
1671 id 'java-library'
1672 id 'maven'
1673}
1674
1675dependencies {
1676 api 'org.apache.commons:commons-text:1.1'
1677}
1678
1679configure(install.repositories.mavenInstaller) {
1680 pom.project {
1681 licenses {
1682 license {
1683 name 'The Apache License, Version 2.0'
1684 url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
1685 }
1686 }
1687 }
1688}
1689"#;
1690
1691 let temp_dir = tempdir().unwrap();
1692 let build_gradle = temp_dir.path().join("build.gradle");
1693 std::fs::write(&build_gradle, content).unwrap();
1694
1695 let package_data = GradleParser::extract_first_package(&build_gradle);
1696
1697 assert_eq!(
1698 package_data.extracted_license_statement,
1699 Some(
1700 "- license:\n name: The Apache License, Version 2.0\n url: http://www.apache.org/licenses/LICENSE-2.0.txt\n"
1701 .to_string()
1702 )
1703 );
1704 assert_eq!(
1705 package_data.declared_license_expression_spdx,
1706 Some("Apache-2.0".to_string())
1707 );
1708 }
1709
1710 #[test]
1711 fn test_parse_gradle_version_catalog_helper() {
1712 let temp_dir = tempdir().unwrap();
1713 let catalog_path = temp_dir.path().join("libs.versions.toml");
1714 std::fs::write(
1715 &catalog_path,
1716 r#"
1717[versions]
1718androidxAppcompat = "1.7.0"
1719
1720[libraries]
1721androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
1722"#,
1723 )
1724 .unwrap();
1725
1726 let entries = parse_gradle_version_catalog(&catalog_path).unwrap();
1727 let entry = entries.get("androidx.appcompat").unwrap();
1728
1729 assert_eq!(entry.namespace, "androidx.appcompat");
1730 assert_eq!(entry.name, "appcompat");
1731 assert_eq!(entry.version.as_deref(), Some("1.7.0"));
1732 }
1733
1734 #[test]
1735 fn test_string_interpolation() {
1736 let content = r#"
1737dependencies {
1738 compile "com.amazonaws:aws-java-sdk-core:${awsVer}"
1739}
1740"#;
1741 let tokens = lex(content);
1742 let deps = extract_dependencies(&tokens);
1743 assert_eq!(deps.len(), 1);
1744 assert_eq!(deps[0].extracted_requirement, Some("${awsVer}".to_string()));
1745 assert_eq!(
1746 deps[0].purl,
1747 Some("pkg:maven/com.amazonaws/aws-java-sdk-core@%24%7BawsVer%7D".to_string())
1748 );
1749 }
1750
1751 #[test]
1752 fn test_multi_value_string_notation() {
1753 let content = r#"
1754dependencies {
1755 runtimeOnly 'org.springframework:spring-core:2.5',
1756 'org.springframework:spring-aop:2.5'
1757}
1758"#;
1759 let tokens = lex(content);
1760 let deps = extract_dependencies(&tokens);
1761 assert_eq!(deps.len(), 2);
1762 assert_eq!(deps[0].scope, Some("".to_string()));
1763 assert_eq!(deps[1].scope, Some("".to_string()));
1764 }
1765
1766 #[test]
1767 fn test_kotlin_quoted_scope_not_extracted() {
1768 let content = r#"
1769dependencies {
1770 "js"("jquery:jquery:3.2.1@js")
1771}
1772"#;
1773 let tokens = lex(content);
1774 let deps = extract_dependencies(&tokens);
1775 assert_eq!(deps.len(), 0);
1776 }
1777
1778 #[test]
1779 fn test_kotlin_quoted_scope_project_reference_extracted() {
1780 let content = r#"
1781subprojects {
1782 dependencies {
1783 "testImplementation"(project(":utils:test-utils"))
1784 }
1785}
1786"#;
1787 let tokens = lex(content);
1788 let deps = extract_dependencies(&tokens);
1789 assert_eq!(deps.len(), 1);
1790 assert_eq!(deps[0].scope, Some("project".to_string()));
1791 assert_eq!(deps[0].purl, Some("pkg:maven/utils/test-utils".to_string()));
1792 }
1793
1794 #[test]
1795 fn test_closure_after_dependency() {
1796 let content = r#"
1797dependencies {
1798 runtimeOnly('org.hibernate:hibernate:3.0.5') {
1799 transitive = true
1800 }
1801}
1802"#;
1803 let tokens = lex(content);
1804 let deps = extract_dependencies(&tokens);
1805 assert_eq!(deps.len(), 1);
1806 assert_eq!(
1807 deps[0].purl,
1808 Some("pkg:maven/org.hibernate/hibernate@3.0.5".to_string())
1809 );
1810 assert_eq!(deps[0].scope, Some("runtimeOnly".to_string()));
1811 }
1812}