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