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