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