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