1use std::collections::{HashMap, HashSet};
28use std::path::{Path, PathBuf};
29use std::sync::{Mutex, OnceLock};
30
31use crate::parser_warn as warn;
32use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
33
34const MAX_RECURSION_DEPTH: usize = 50;
35use packageurl::PackageUrl;
36use serde_json::json;
37
38use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
39use crate::parsers::PackageParser;
40
41use super::license_normalization::{
42 DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
43 normalize_spdx_expression,
44};
45
46pub struct GradleParser;
70
71impl PackageParser for GradleParser {
72 const PACKAGE_TYPE: PackageType = PackageType::Maven;
73
74 fn is_match(path: &Path) -> bool {
75 path.file_name().is_some_and(|name| {
76 let name_str = name.to_string_lossy();
77 name_str == "build.gradle" || name_str == "build.gradle.kts"
78 })
79 }
80
81 fn extract_packages(path: &Path) -> Vec<PackageData> {
82 let content = match read_file_to_string(path, None) {
83 Ok(c) => c,
84 Err(e) => {
85 warn!("Failed to read {:?}: {}", path, e);
86 return vec![default_package_data()];
87 }
88 };
89
90 let tokens = lex(&content);
91 let dependencies = extract_dependencies_with_context(path, &content, &tokens);
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 => {
331 depth += 1;
332 if depth > MAX_RECURSION_DEPTH {
333 warn!(
334 "Gradle parser: nesting depth exceeded {} in find_dependency_blocks",
335 MAX_RECURSION_DEPTH
336 );
337 break;
338 }
339 }
340 Tok::CloseBrace => depth -= 1,
341 _ => {}
342 }
343 if depth > 0 {
344 i += 1;
345 }
346 }
347 blocks.push(tokens[start..i].to_vec());
348 if i < tokens.len() {
349 i += 1;
350 }
351 continue;
352 }
353 i += 1;
354 }
355
356 blocks
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Hash)]
364struct RawDep {
365 namespace: String,
366 name: String,
367 version: String,
368 scope: String,
369 catalog_alias: Option<String>,
370 symbolic_ref: Option<String>,
371 project_path: Option<String>,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
375enum BuildSrcExpr {
376 Literal(String),
377 Ref(String),
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381struct BuildSrcConst {
382 scope: String,
383 expr: BuildSrcExpr,
384}
385
386type BuildSrcConstMap = HashMap<String, BuildSrcConst>;
387type BuildSrcCache = HashMap<PathBuf, Option<BuildSrcConstMap>>;
388
389static BUILD_SRC_CONSTANT_CACHE: OnceLock<Mutex<BuildSrcCache>> = OnceLock::new();
390
391fn extract_dependencies_with_context(
392 path: &Path,
393 content: &str,
394 tokens: &[Tok],
395) -> Vec<Dependency> {
396 let mut raw_dependencies = extract_raw_dependencies(tokens);
397 resolve_gradle_script_interpolations(path, content, &mut raw_dependencies);
398 resolve_gradle_buildsrc_symbolic_refs(path, &mut raw_dependencies);
399 let mut dependencies = raw_dependencies
400 .iter()
401 .filter_map(create_dependency)
402 .collect::<Vec<_>>();
403 resolve_gradle_version_catalog_aliases(path, &mut dependencies);
404 dependencies
405}
406
407#[cfg(test)]
408fn extract_dependencies(tokens: &[Tok]) -> Vec<Dependency> {
409 extract_raw_dependencies(tokens)
410 .iter()
411 .filter_map(create_dependency)
412 .collect()
413}
414
415fn extract_raw_dependencies(tokens: &[Tok]) -> Vec<RawDep> {
416 let blocks = find_dependency_blocks(tokens);
417 let mut dependencies = Vec::new();
418
419 for block in blocks {
420 for rd in parse_block(&block).into_iter().take(MAX_ITERATION_COUNT) {
421 dependencies.push(rd);
422 }
423 }
424
425 dependencies
426}
427
428fn parse_block(tokens: &[Tok]) -> Vec<RawDep> {
429 let mut deps = Vec::new();
430 let mut i = 0;
431 let mut iterations = 0;
432
433 while i < tokens.len() {
434 iterations += 1;
435 if iterations > MAX_ITERATION_COUNT {
436 warn!(
437 "parse_block exceeded MAX_ITERATION_COUNT ({}) iterations, stopping",
438 MAX_ITERATION_COUNT
439 );
440 break;
441 }
442 if tokens[i] == Tok::OpenBrace {
444 let mut depth = 1;
445 i += 1;
446 while i < tokens.len() && depth > 0 {
447 match &tokens[i] {
448 Tok::OpenBrace => {
449 depth += 1;
450 if depth > MAX_RECURSION_DEPTH {
451 warn!(
452 "Gradle parser: nesting depth exceeded {} in parse_block",
453 MAX_RECURSION_DEPTH
454 );
455 break;
456 }
457 }
458 Tok::CloseBrace => depth -= 1,
459 _ => {}
460 }
461 i += 1;
462 }
463 continue;
464 }
465
466 if let Tok::Str(scope_name) = &tokens[i]
467 && i + 1 < tokens.len()
468 && tokens[i + 1] == Tok::OpenParen
469 && let Some(end) = find_matching_paren(tokens, i + 1)
470 {
471 let inner = &tokens[i + 2..end];
472 parse_paren_content(scope_name, inner, &mut deps);
473 i = end + 1;
474 continue;
475 }
476
477 let scope_name = match &tokens[i] {
478 Tok::Ident(name) => name.clone(),
479 _ => {
480 i += 1;
481 continue;
482 }
483 };
484
485 if is_skip_keyword(&scope_name) {
486 i += 1;
487 continue;
488 }
489
490 let next = i + 1;
491
492 if next < tokens.len() && tokens[next] == Tok::OpenParen {
494 let paren_end = find_matching_paren(tokens, next);
495 if let Some(end) = paren_end {
496 let inner = &tokens[next + 1..end];
497 parse_paren_content(&scope_name, inner, &mut deps);
498 i = end + 1;
499 continue;
500 }
501 }
502
503 if next < tokens.len()
505 && let Tok::Ident(ref label) = tokens[next]
506 && label == "group"
507 && next + 1 < tokens.len()
508 && tokens[next + 1] == Tok::Colon
509 && let Some((rd, consumed)) = parse_named_params(&scope_name, &tokens[next..])
510 {
511 deps.push(rd);
512 i = next + consumed;
513 continue;
514 }
515
516 if next < tokens.len()
518 && matches!(
519 tokens.get(next),
520 Some(Tok::Str(_)) | Some(Tok::MalformedStr(_))
521 )
522 {
523 let (val, is_malformed) = match &tokens[next] {
524 Tok::Str(val) => (val.as_str(), false),
525 Tok::MalformedStr(val) => (val.as_str(), true),
526 _ => unreachable!(),
527 };
528
529 if !val.contains(':') {
530 i = next + 1;
531 continue;
532 }
533
534 if val.chars().next().is_some_and(|c| c.is_whitespace()) {
535 break;
536 }
537
538 if next + 1 < tokens.len()
540 && tokens[next + 1] == Tok::Comma
541 && next + 2 < tokens.len()
542 && tokens[next + 2] == Tok::OpenBrace
543 {
544 i = next + 1;
545 continue;
546 }
547 let is_multi = i + 2 < tokens.len()
548 && tokens[next + 1] == Tok::Comma
549 && matches!(tokens.get(next + 2), Some(Tok::Str(_)));
550 let effective_scope = if is_multi { "" } else { &scope_name };
551 let rd = parse_colon_string(val, effective_scope);
552 deps.push(rd);
553 if is_malformed {
554 break;
555 }
556 i = next + 1;
557 while i < tokens.len() && tokens[i] == Tok::Comma {
558 i += 1;
559 if i < tokens.len()
560 && let Tok::Str(ref v2) = tokens[i]
561 && v2.contains(':')
562 {
563 deps.push(parse_colon_string(v2, ""));
564 i += 1;
565 continue;
566 }
567 break;
568 }
569 continue;
570 }
571
572 if next < tokens.len()
577 && let Tok::Ident(ref val) = tokens[next]
578 && val.starts_with("libs.")
579 && let Some(last_seg) = val.rsplit('.').next()
580 && !last_seg.is_empty()
581 {
582 deps.push(RawDep {
583 namespace: String::new(),
584 name: truncate_field(last_seg.to_string()),
585 version: String::new(),
586 scope: truncate_field(scope_name.clone()),
587 catalog_alias: val
588 .strip_prefix("libs.")
589 .map(|alias| truncate_field(alias.to_string())),
590 symbolic_ref: None,
591 project_path: None,
592 });
593 i = next + 1;
594 continue;
595 }
596
597 if next < tokens.len()
598 && let Tok::Ident(ref val) = tokens[next]
599 && val.contains('.')
600 {
601 deps.push(parse_symbolic_ref(&scope_name, val));
602 i = next + 1;
603 continue;
604 }
605
606 if next < tokens.len()
608 && let Tok::Ident(ref name) = tokens[next]
609 && name == "project"
610 && next + 1 < tokens.len()
611 && tokens[next + 1] == Tok::OpenParen
612 && let Some(end) = find_matching_paren(tokens, next + 1)
613 {
614 let inner = &tokens[next + 2..end];
615 if let Some(rd) = parse_project_ref(inner, &scope_name) {
616 deps.push(rd);
617 }
618 i = end + 1;
619 continue;
620 }
621
622 i += 1;
623 }
624
625 deps
626}
627
628fn is_skip_keyword(name: &str) -> bool {
629 matches!(
630 name,
631 "plugins"
632 | "apply"
633 | "ext"
634 | "configurations"
635 | "repositories"
636 | "subprojects"
637 | "allprojects"
638 | "buildscript"
639 | "pluginManager"
640 | "publishing"
641 | "sourceSets"
642 | "tasks"
643 | "task"
644 )
645}
646
647fn parse_paren_content(scope: &str, tokens: &[Tok], deps: &mut Vec<RawDep>) {
648 if tokens.is_empty() {
649 return;
650 }
651
652 if tokens[0] == Tok::OpenBracket {
654 parse_bracket_maps(tokens, deps);
655 return;
656 }
657
658 if let Some(Tok::Ident(label)) = tokens.first()
660 && label == "group"
661 && tokens.len() > 1
662 && tokens[1] == Tok::Colon
663 {
664 if let Some((rd, _)) = parse_named_params("", tokens) {
665 deps.push(rd);
666 }
667 return;
668 }
669
670 if let Some(Tok::Ident(inner_fn)) = tokens.first()
672 && tokens.len() > 1
673 && tokens[1] == Tok::OpenParen
674 {
675 if inner_fn == "project" {
676 if let Some(end) = find_matching_paren(tokens, 1) {
677 let inner = &tokens[2..end];
678 if let Some(rd) = parse_project_ref(inner, scope) {
679 deps.push(rd);
680 }
681 }
682 return;
683 }
684
685 if let Some(end) = find_matching_paren(tokens, 1) {
686 let inner = &tokens[2..end];
687 if let Some(Tok::Str(val)) = inner.first()
688 && val.contains(':')
689 {
690 deps.push(parse_colon_string(val, inner_fn));
691 return;
692 }
693
694 if let Some(Tok::Ident(val)) = inner.first()
695 && val.contains('.')
696 {
697 deps.push(parse_symbolic_ref(inner_fn, val));
698 return;
699 }
700 }
701 }
702
703 if let Some(Tok::Ident(val)) = tokens.first()
704 && val.contains('.')
705 {
706 deps.push(parse_symbolic_ref(scope, val));
707 return;
708 }
709
710 if let Some(Tok::Str(val)) = tokens.first()
712 && val.contains(':')
713 {
714 deps.push(parse_colon_string(val, scope));
715 }
716}
717
718fn parse_bracket_maps(tokens: &[Tok], deps: &mut Vec<RawDep>) {
719 let mut i = 0;
720 while i < tokens.len() {
721 if tokens[i] == Tok::OpenBracket
722 && let Some(end) = find_matching_bracket(tokens, i)
723 {
724 let map_tokens = &tokens[i + 1..end];
725 if let Some(rd) = parse_map_entries(map_tokens)
726 && !contains_equivalent_map_dep(deps, &rd)
727 {
728 deps.push(rd);
729 }
730 i = end + 1;
731 continue;
732 }
733 i += 1;
734 }
735}
736
737fn contains_equivalent_map_dep(existing: &[RawDep], candidate: &RawDep) -> bool {
738 existing.iter().any(|dep| {
739 dep.name == candidate.name
740 && dep.version == candidate.version
741 && dep.scope == candidate.scope
742 && (dep.namespace == candidate.namespace
743 || dep.namespace.is_empty()
744 || candidate.namespace.is_empty())
745 })
746}
747
748fn parse_map_entries(tokens: &[Tok]) -> Option<RawDep> {
749 let mut name = String::new();
750 let mut version = String::new();
751 let mut i = 0;
752
753 while i < tokens.len() {
754 if let Tok::Ident(ref label) = tokens[i]
755 && i + 2 < tokens.len()
756 && tokens[i + 1] == Tok::Colon
757 && let Tok::Str(ref val) = tokens[i + 2]
758 {
759 match label.as_str() {
760 "name" => name = truncate_field(val.clone()),
761 "version" => version = truncate_field(val.clone()),
762 _ => {}
763 }
764 i += 3;
765 if i < tokens.len() && tokens[i] == Tok::Comma {
766 i += 1;
767 }
768 continue;
769 }
770 i += 1;
771 }
772
773 if name.is_empty() {
774 return None;
775 }
776
777 Some(RawDep {
778 namespace: String::new(),
779 name,
780 version,
781 scope: String::new(),
782 catalog_alias: None,
783 symbolic_ref: None,
784 project_path: None,
785 })
786}
787
788fn parse_named_params(scope: &str, tokens: &[Tok]) -> Option<(RawDep, usize)> {
789 let mut group = String::new();
790 let mut name = String::new();
791 let mut version = String::new();
792 let mut i = 0;
793
794 while i < tokens.len() {
795 if let Tok::Ident(ref label) = tokens[i]
796 && i + 2 < tokens.len()
797 && tokens[i + 1] == Tok::Colon
798 && let Tok::Str(ref val) = tokens[i + 2]
799 {
800 match label.as_str() {
801 "group" => group = truncate_field(val.clone()),
802 "name" => name = truncate_field(val.clone()),
803 "version" => version = truncate_field(val.clone()),
804 _ => {}
805 }
806 i += 3;
807 if i < tokens.len() && tokens[i] == Tok::Comma {
808 i += 1;
809 }
810 continue;
811 }
812 break;
813 }
814
815 if name.is_empty() {
816 return None;
817 }
818
819 Some((
820 RawDep {
821 namespace: group,
822 name,
823 version,
824 scope: scope.to_string(),
825 catalog_alias: None,
826 symbolic_ref: None,
827 project_path: None,
828 },
829 i,
830 ))
831}
832
833fn parse_project_ref(tokens: &[Tok], scope: &str) -> Option<RawDep> {
834 if let Some(Tok::Str(val)) = tokens.first() {
835 let module_name = val.trim_start_matches(':');
836 let mut segments = module_name
837 .split(':')
838 .filter(|segment| !segment.is_empty())
839 .collect::<Vec<_>>();
840 let name = segments.pop().unwrap_or(module_name);
841 if name.is_empty() {
842 return None;
843 }
844 return Some(RawDep {
845 namespace: if segments.is_empty() {
846 String::new()
847 } else {
848 truncate_field(segments.join("/"))
849 },
850 name: truncate_field(name.to_string()),
851 version: String::new(),
852 scope: truncate_field(scope.to_string()),
853 catalog_alias: None,
854 symbolic_ref: None,
855 project_path: Some(truncate_field(module_name.to_string())),
856 });
857 }
858 None
859}
860
861fn parse_symbolic_ref(scope: &str, value: &str) -> RawDep {
862 RawDep {
863 namespace: String::new(),
864 name: String::new(),
865 version: String::new(),
866 scope: truncate_field(scope.to_string()),
867 catalog_alias: None,
868 symbolic_ref: Some(truncate_field(value.to_string())),
869 project_path: None,
870 }
871}
872
873fn parse_colon_string(val: &str, scope: &str) -> RawDep {
874 let parts: Vec<&str> = val.split(':').collect();
875 let (namespace, name, version) = match parts.len() {
876 n if n >= 4 => (
877 truncate_field(parts[0].to_string()),
878 truncate_field(parts[1].to_string()),
879 truncate_field(parts[2].to_string()),
880 ),
881 3 => (
882 truncate_field(parts[0].to_string()),
883 truncate_field(parts[1].to_string()),
884 truncate_field(parts[2].to_string()),
885 ),
886 2 => (
887 truncate_field(parts[0].to_string()),
888 truncate_field(parts[1].to_string()),
889 String::new(),
890 ),
891 _ => (
892 String::new(),
893 truncate_field(val.to_string()),
894 String::new(),
895 ),
896 };
897
898 RawDep {
899 namespace,
900 name,
901 version,
902 scope: truncate_field(scope.to_string()),
903 catalog_alias: None,
904 symbolic_ref: None,
905 project_path: None,
906 }
907}
908
909fn find_matching_paren(tokens: &[Tok], start: usize) -> Option<usize> {
910 if tokens.get(start) != Some(&Tok::OpenParen) {
911 return None;
912 }
913 let mut depth = 1;
914 let mut i = start + 1;
915 while i < tokens.len() && depth > 0 {
916 match &tokens[i] {
917 Tok::OpenParen => {
918 depth += 1;
919 if depth > MAX_RECURSION_DEPTH {
920 warn!(
921 "Gradle parser: nesting depth exceeded {} in find_matching_paren",
922 MAX_RECURSION_DEPTH
923 );
924 break;
925 }
926 }
927 Tok::CloseParen => depth -= 1,
928 _ => {}
929 }
930 if depth == 0 {
931 return Some(i);
932 }
933 i += 1;
934 }
935 None
936}
937
938fn find_matching_bracket(tokens: &[Tok], start: usize) -> Option<usize> {
939 if tokens.get(start) != Some(&Tok::OpenBracket) {
940 return None;
941 }
942 let mut depth = 1;
943 let mut i = start + 1;
944 while i < tokens.len() && depth > 0 {
945 match &tokens[i] {
946 Tok::OpenBracket => {
947 depth += 1;
948 if depth > MAX_RECURSION_DEPTH {
949 warn!(
950 "Gradle parser: nesting depth exceeded {} in find_matching_bracket",
951 MAX_RECURSION_DEPTH
952 );
953 break;
954 }
955 }
956 Tok::CloseBracket => depth -= 1,
957 _ => {}
958 }
959 if depth == 0 {
960 return Some(i);
961 }
962 i += 1;
963 }
964 None
965}
966
967fn create_dependency(raw: &RawDep) -> Option<Dependency> {
972 let namespace = raw.namespace.as_str();
973 let name = raw.name.as_str();
974 let version = raw.version.as_str();
975 let scope = raw.scope.as_str();
976 if name.is_empty() {
977 return None;
978 }
979
980 let mut purl = PackageUrl::new("maven", name).ok()?;
981
982 if !namespace.is_empty() {
983 purl.with_namespace(namespace).ok()?;
984 }
985
986 if !version.is_empty() {
987 purl.with_version(version).ok()?;
988 }
989
990 let (is_runtime, is_optional) = classify_scope(scope);
991 let is_pinned = !version.is_empty();
992
993 let purl_string = truncate_field(purl.to_string().replace("$", "%24").replace('\'', "%27"));
994 let mut extra_data = std::collections::HashMap::new();
995 if let Some(alias) = &raw.catalog_alias {
996 extra_data.insert(
997 "catalog_alias".to_string(),
998 json!(truncate_field(alias.clone())),
999 );
1000 }
1001 if let Some(project_path) = &raw.project_path {
1002 extra_data.insert(
1003 "project_path".to_string(),
1004 json!(truncate_field(project_path.clone())),
1005 );
1006 }
1007 if let Some(symbolic_ref) = &raw.symbolic_ref {
1008 extra_data.insert(
1009 "symbolic_ref".to_string(),
1010 json!(truncate_field(symbolic_ref.clone())),
1011 );
1012 }
1013
1014 Some(Dependency {
1015 purl: Some(purl_string),
1016 extracted_requirement: Some(truncate_field(version.to_string())),
1017 scope: Some(truncate_field(scope.to_string())),
1018 is_runtime: Some(is_runtime),
1019 is_optional: Some(is_optional),
1020 is_pinned: Some(is_pinned),
1021 is_direct: Some(true),
1022 resolved_package: None,
1023 extra_data: (!extra_data.is_empty()).then_some(extra_data),
1024 })
1025}
1026
1027fn classify_scope(scope: &str) -> (bool, bool) {
1028 let scope_lower = scope.to_lowercase();
1029
1030 if scope_lower.contains("test") {
1031 return (false, true);
1032 }
1033
1034 if matches!(
1035 scope_lower.as_str(),
1036 "compileonly" | "compileonlyapi" | "annotationprocessor" | "kapt" | "ksp"
1037 ) {
1038 return (false, false);
1039 }
1040
1041 (true, false)
1042}
1043
1044fn resolve_gradle_script_interpolations(
1045 path: &Path,
1046 content: &str,
1047 raw_dependencies: &mut [RawDep],
1048) {
1049 let properties = load_gradle_script_properties(path, content);
1050 if properties.is_empty() {
1051 return;
1052 }
1053
1054 for raw in raw_dependencies.iter_mut() {
1055 raw.namespace = interpolate_gradle_string(&raw.namespace, &properties);
1056 raw.name = interpolate_gradle_string(&raw.name, &properties);
1057 raw.version = interpolate_gradle_string(&raw.version, &properties);
1058 }
1059}
1060
1061fn load_gradle_script_properties(path: &Path, content: &str) -> HashMap<String, String> {
1062 let mut properties = load_gradle_properties(path);
1063
1064 let literal_assignment_patterns = [
1065 regex::Regex::new(
1066 r#"(?m)^\s*(?:const\s+)?(?:val|var|def)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=\s*['\"]([^'\"]+)['\"]"#,
1067 )
1068 .expect("valid regex"),
1069 regex::Regex::new(r#"(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['\"]([^'\"]+)['\"]"#)
1070 .expect("valid regex"),
1071 ];
1072
1073 for pattern in literal_assignment_patterns {
1074 for captures in pattern.captures_iter(content).take(MAX_ITERATION_COUNT) {
1075 let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1076 continue;
1077 };
1078 let Some(raw_value) = captures.get(2).map(|value| value.as_str()) else {
1079 continue;
1080 };
1081 let resolved = interpolate_gradle_string(raw_value, &properties);
1082 properties.insert(name.to_string(), resolved);
1083 }
1084 }
1085
1086 let delegated_project_property_pattern = regex::Regex::new(
1087 r#"(?m)^\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]+)?\s+by\s+project\b"#,
1088 )
1089 .expect("valid regex");
1090
1091 for captures in delegated_project_property_pattern
1092 .captures_iter(content)
1093 .take(MAX_ITERATION_COUNT)
1094 {
1095 let Some(name) = captures.get(1).map(|value| value.as_str().trim()) else {
1096 continue;
1097 };
1098 if let Some(value) = properties.get(name).cloned() {
1099 properties.insert(name.to_string(), value);
1100 }
1101 }
1102
1103 properties
1104}
1105
1106fn load_gradle_properties(path: &Path) -> HashMap<String, String> {
1107 for ancestor in path.ancestors() {
1108 let gradle_properties = ancestor.join("gradle.properties");
1109 if !gradle_properties.is_file() {
1110 continue;
1111 }
1112
1113 let Ok(content) = read_file_to_string(&gradle_properties, None) else {
1114 continue;
1115 };
1116
1117 let mut properties = HashMap::new();
1118 for line in content.lines().take(MAX_ITERATION_COUNT) {
1119 let trimmed = line.split('#').next().unwrap_or("").trim();
1120 if trimmed.is_empty() {
1121 continue;
1122 }
1123
1124 let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':'))
1125 else {
1126 continue;
1127 };
1128
1129 let key = key.trim();
1130 let value = value.trim();
1131 if key.is_empty() || value.is_empty() {
1132 continue;
1133 }
1134 properties.insert(key.to_string(), value.to_string());
1135 }
1136 return properties;
1137 }
1138
1139 HashMap::new()
1140}
1141
1142fn interpolate_gradle_string(value: &str, properties: &HashMap<String, String>) -> String {
1143 if !value.contains('$') {
1144 return truncate_field(value.to_string());
1145 }
1146
1147 let chars = value.chars().collect::<Vec<_>>();
1148 let mut rendered = String::new();
1149 let mut i = 0;
1150
1151 while i < chars.len() {
1152 if chars[i] != '$' {
1153 rendered.push(chars[i]);
1154 i += 1;
1155 continue;
1156 }
1157
1158 if i + 1 >= chars.len() {
1159 rendered.push(chars[i]);
1160 break;
1161 }
1162
1163 if chars[i + 1] == '{' {
1164 let start = i;
1165 i += 2;
1166 let mut reference = String::new();
1167 while i < chars.len() && chars[i] != '}' {
1168 reference.push(chars[i]);
1169 i += 1;
1170 }
1171 if i < chars.len() && chars[i] == '}' {
1172 i += 1;
1173 }
1174
1175 if let Some(resolved) = properties.get(reference.trim()) {
1176 rendered.push_str(resolved);
1177 } else {
1178 rendered.push_str(&value[start..i]);
1179 }
1180 continue;
1181 }
1182
1183 let start = i;
1184 i += 1;
1185 let mut reference = String::new();
1186 while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_') {
1187 reference.push(chars[i]);
1188 i += 1;
1189 }
1190
1191 if reference.is_empty() {
1192 rendered.push('$');
1193 continue;
1194 }
1195
1196 if let Some(resolved) = properties.get(reference.as_str()) {
1197 rendered.push_str(resolved);
1198 } else {
1199 rendered.push_str(&value[start..i]);
1200 }
1201 }
1202
1203 truncate_field(rendered)
1204}
1205
1206fn resolve_gradle_buildsrc_symbolic_refs(path: &Path, raw_dependencies: &mut [RawDep]) {
1207 let Some(build_src_dir) = find_build_src_dir(path) else {
1208 return;
1209 };
1210 let Some(constants) = load_build_src_constants(&build_src_dir) else {
1211 return;
1212 };
1213
1214 for raw in raw_dependencies.iter_mut() {
1215 let Some(symbolic_ref) = raw.symbolic_ref.as_deref() else {
1216 continue;
1217 };
1218
1219 let mut visiting = HashSet::new();
1220 let Some(resolved) = resolve_build_src_value(symbolic_ref, &constants, &mut visiting)
1221 else {
1222 continue;
1223 };
1224 if !resolved.contains(':') {
1225 continue;
1226 }
1227
1228 let resolved_dependency = parse_colon_string(&resolved, &raw.scope);
1229 raw.namespace = resolved_dependency.namespace;
1230 raw.name = resolved_dependency.name;
1231 raw.version = resolved_dependency.version;
1232 }
1233}
1234
1235fn find_build_src_dir(path: &Path) -> Option<PathBuf> {
1236 for ancestor in path.ancestors() {
1237 let build_src_dir = ancestor.join("buildSrc");
1238 if build_src_dir.is_dir() {
1239 return Some(build_src_dir);
1240 }
1241 }
1242 None
1243}
1244
1245fn load_build_src_constants(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1246 let cache = BUILD_SRC_CONSTANT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1247 if let Ok(guard) = cache.lock()
1248 && let Some(cached) = guard.get(build_src_dir)
1249 {
1250 return cached.clone();
1251 }
1252
1253 let parsed = parse_build_src_constants_dir(build_src_dir);
1254
1255 if let Ok(mut guard) = cache.lock() {
1256 guard.insert(build_src_dir.to_path_buf(), parsed.clone());
1257 }
1258
1259 parsed
1260}
1261
1262fn parse_build_src_constants_dir(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1263 let mut kotlin_files = Vec::new();
1264 for source_dir in [
1265 build_src_dir.join("src").join("main").join("java"),
1266 build_src_dir.join("src").join("main").join("kotlin"),
1267 ] {
1268 collect_build_src_kotlin_files(&source_dir, &mut kotlin_files);
1269 }
1270
1271 if kotlin_files.is_empty() {
1272 return None;
1273 }
1274
1275 let mut constants = HashMap::new();
1276 for file in kotlin_files.into_iter().take(MAX_ITERATION_COUNT) {
1277 let Ok(content) = read_file_to_string(&file, None) else {
1278 continue;
1279 };
1280 constants.extend(parse_build_src_constants(&content));
1281 }
1282
1283 (!constants.is_empty()).then_some(constants)
1284}
1285
1286fn collect_build_src_kotlin_files(dir: &Path, files: &mut Vec<PathBuf>) {
1287 if files.len() >= MAX_ITERATION_COUNT || !dir.is_dir() {
1288 return;
1289 }
1290
1291 let Ok(entries) = std::fs::read_dir(dir) else {
1292 return;
1293 };
1294
1295 for entry in entries.flatten().take(MAX_ITERATION_COUNT) {
1296 if files.len() >= MAX_ITERATION_COUNT {
1297 break;
1298 }
1299
1300 let path = entry.path();
1301 if path.is_dir() {
1302 collect_build_src_kotlin_files(&path, files);
1303 continue;
1304 }
1305
1306 if path.extension().is_some_and(|ext| ext == "kt") {
1307 files.push(path);
1308 }
1309 }
1310}
1311
1312fn parse_build_src_constants(content: &str) -> BuildSrcConstMap {
1313 let tokens = lex(content);
1314 let mut constants = HashMap::new();
1315 let mut object_stack = Vec::new();
1316 let mut brace_stack: Vec<Option<String>> = Vec::new();
1317 let mut i = 0;
1318
1319 while i < tokens.len() && i < MAX_ITERATION_COUNT {
1320 if let Some((name, consumed)) = parse_object_declaration(&tokens[i..]) {
1321 object_stack.push(name.clone());
1322 brace_stack.push(Some(name));
1323 i += consumed;
1324 continue;
1325 }
1326
1327 if let Some((name, expr, consumed)) = parse_build_src_const_definition(&tokens[i..]) {
1328 let scope = object_stack.join(".");
1329 let full_name = if scope.is_empty() {
1330 name.clone()
1331 } else {
1332 format!("{scope}.{name}")
1333 };
1334 constants.insert(
1335 truncate_field(full_name),
1336 BuildSrcConst {
1337 scope: truncate_field(scope),
1338 expr,
1339 },
1340 );
1341 i += consumed;
1342 continue;
1343 }
1344
1345 match &tokens[i] {
1346 Tok::OpenBrace => brace_stack.push(None),
1347 Tok::CloseBrace => {
1348 if let Some(marker) = brace_stack.pop()
1349 && marker.is_some()
1350 {
1351 object_stack.pop();
1352 }
1353 }
1354 _ => {}
1355 }
1356
1357 i += 1;
1358 }
1359
1360 constants
1361}
1362
1363fn parse_object_declaration(tokens: &[Tok]) -> Option<(String, usize)> {
1364 if let [Tok::Ident(keyword), Tok::Ident(name), Tok::OpenBrace, ..] = tokens
1365 && keyword == "object"
1366 {
1367 return Some((truncate_field(name.clone()), 3));
1368 }
1369 None
1370}
1371
1372fn parse_build_src_const_definition(tokens: &[Tok]) -> Option<(String, BuildSrcExpr, usize)> {
1373 let mut cursor = 0;
1374
1375 while let Some(Tok::Ident(modifier)) = tokens.get(cursor) {
1376 if matches!(
1377 modifier.as_str(),
1378 "private" | "internal" | "public" | "protected"
1379 ) {
1380 cursor += 1;
1381 continue;
1382 }
1383 break;
1384 }
1385
1386 if !matches!(tokens.get(cursor), Some(Tok::Ident(keyword)) if keyword == "const")
1387 || !matches!(tokens.get(cursor + 1), Some(Tok::Ident(keyword)) if keyword == "val")
1388 {
1389 return None;
1390 }
1391
1392 let Tok::Ident(name) = tokens.get(cursor + 2)? else {
1393 return None;
1394 };
1395 if tokens.get(cursor + 3) != Some(&Tok::Equals) {
1396 return None;
1397 }
1398
1399 let expr = match tokens.get(cursor + 4)? {
1400 Tok::Str(value) => BuildSrcExpr::Literal(truncate_field(value.clone())),
1401 Tok::Ident(value) => BuildSrcExpr::Ref(truncate_field(value.clone())),
1402 _ => return None,
1403 };
1404
1405 Some((truncate_field(name.clone()), expr, cursor + 5))
1406}
1407
1408fn resolve_build_src_value(
1409 key: &str,
1410 constants: &BuildSrcConstMap,
1411 visiting: &mut HashSet<String>,
1412) -> Option<String> {
1413 if !visiting.insert(key.to_string()) {
1414 return None;
1415 }
1416
1417 let resolved = constants
1418 .get(key)
1419 .and_then(|constant| resolve_build_src_expr(constant, constants, visiting));
1420 visiting.remove(key);
1421 resolved
1422}
1423
1424fn resolve_build_src_expr(
1425 constant: &BuildSrcConst,
1426 constants: &BuildSrcConstMap,
1427 visiting: &mut HashSet<String>,
1428) -> Option<String> {
1429 match &constant.expr {
1430 BuildSrcExpr::Literal(value) => Some(interpolate_build_src_string(
1431 value,
1432 &constant.scope,
1433 constants,
1434 visiting,
1435 )),
1436 BuildSrcExpr::Ref(reference) => {
1437 resolve_build_src_symbol(&constant.scope, reference, constants, visiting)
1438 }
1439 }
1440}
1441
1442fn resolve_build_src_symbol(
1443 scope: &str,
1444 reference: &str,
1445 constants: &BuildSrcConstMap,
1446 visiting: &mut HashSet<String>,
1447) -> Option<String> {
1448 if reference.contains('.') {
1449 return resolve_build_src_value(reference, constants, visiting);
1450 }
1451
1452 let mut current_scope = Some(scope);
1453 while let Some(scope_name) = current_scope {
1454 if !scope_name.is_empty() {
1455 let candidate = format!("{scope_name}.{reference}");
1456 if let Some(value) = resolve_build_src_value(&candidate, constants, visiting) {
1457 return Some(value);
1458 }
1459 }
1460
1461 current_scope = scope_name.rsplit_once('.').map(|(parent, _)| parent);
1462 }
1463
1464 resolve_build_src_value(reference, constants, visiting)
1465}
1466
1467fn interpolate_build_src_string(
1468 value: &str,
1469 scope: &str,
1470 constants: &BuildSrcConstMap,
1471 visiting: &mut HashSet<String>,
1472) -> String {
1473 let chars = value.chars().collect::<Vec<_>>();
1474 let mut rendered = String::new();
1475 let mut i = 0;
1476
1477 while i < chars.len() {
1478 if chars[i] != '$' {
1479 rendered.push(chars[i]);
1480 i += 1;
1481 continue;
1482 }
1483
1484 if i + 1 >= chars.len() {
1485 rendered.push(chars[i]);
1486 break;
1487 }
1488
1489 if chars[i + 1] == '{' {
1490 let start = i;
1491 i += 2;
1492 let mut reference = String::new();
1493 while i < chars.len() && chars[i] != '}' {
1494 reference.push(chars[i]);
1495 i += 1;
1496 }
1497 if i < chars.len() && chars[i] == '}' {
1498 i += 1;
1499 }
1500
1501 if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting)
1502 {
1503 rendered.push_str(&resolved);
1504 } else {
1505 rendered.push_str(&value[start..i]);
1506 }
1507 continue;
1508 }
1509
1510 let start = i;
1511 i += 1;
1512 let mut reference = String::new();
1513 while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.') {
1514 reference.push(chars[i]);
1515 i += 1;
1516 }
1517
1518 if reference.is_empty() {
1519 rendered.push('$');
1520 continue;
1521 }
1522
1523 if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting) {
1524 rendered.push_str(&resolved);
1525 } else {
1526 rendered.push_str(&value[start..i]);
1527 }
1528 }
1529
1530 truncate_field(rendered)
1531}
1532
1533#[derive(Debug, Clone)]
1534struct GradleCatalogEntry {
1535 namespace: String,
1536 name: String,
1537 version: Option<String>,
1538}
1539
1540fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
1541 let Some(catalog_path) = find_gradle_version_catalog(path) else {
1542 return;
1543 };
1544 let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
1545 return;
1546 };
1547
1548 for dep in dependencies.iter_mut() {
1549 let alias = dep
1550 .extra_data
1551 .as_ref()
1552 .and_then(|data| data.get("catalog_alias"))
1553 .and_then(|value| value.as_str());
1554 let Some(alias) = alias else {
1555 continue;
1556 };
1557 let Some(entry) = entries.get(alias) else {
1558 continue;
1559 };
1560
1561 let mut purl = PackageUrl::new("maven", &entry.name).ok();
1562 if let Some(ref mut purl) = purl {
1563 if !entry.namespace.is_empty() {
1564 let _ = purl.with_namespace(&entry.namespace);
1565 }
1566 if let Some(version) = &entry.version {
1567 let _ = purl.with_version(version);
1568 }
1569 }
1570
1571 dep.purl = purl.map(|p| truncate_field(p.to_string()));
1572 dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1573 dep.is_pinned = Some(entry.version.is_some());
1574 }
1575}
1576
1577fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1578 for ancestor in path.ancestors() {
1579 let nested = ancestor.join("gradle").join("libs.versions.toml");
1580 if nested.is_file() {
1581 return Some(nested);
1582 }
1583
1584 let sibling = ancestor.join("libs.versions.toml");
1585 if sibling.is_file() {
1586 return Some(sibling);
1587 }
1588 }
1589
1590 None
1591}
1592
1593fn parse_gradle_version_catalog(
1594 path: &Path,
1595) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1596 let content = read_file_to_string(path, None).ok()?;
1597 let mut section = "";
1598 let mut versions = std::collections::HashMap::new();
1599 let mut libraries = std::collections::HashMap::new();
1600
1601 for line in content.lines().take(MAX_ITERATION_COUNT) {
1602 let trimmed = line.split('#').next().unwrap_or("").trim();
1603 if trimmed.is_empty() {
1604 continue;
1605 }
1606
1607 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1608 section = trimmed.trim_matches(&['[', ']'][..]);
1609 continue;
1610 }
1611
1612 let Some((key, value)) = trimmed.split_once('=') else {
1613 continue;
1614 };
1615 let key = key.trim().to_string();
1616 let value = value.trim().to_string();
1617
1618 match section {
1619 "versions" => {
1620 versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1621 }
1622 "libraries" => {
1623 libraries.insert(key, value);
1624 }
1625 _ => {}
1626 }
1627 }
1628
1629 let mut result = std::collections::HashMap::new();
1630 for (alias, raw_value) in libraries.into_iter().take(MAX_ITERATION_COUNT) {
1631 let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1632 continue;
1633 };
1634 result.insert(truncate_field(alias.replace('-', ".")), entry);
1635 }
1636
1637 Some(result)
1638}
1639
1640fn parse_gradle_catalog_entry(
1641 raw_value: &str,
1642 versions: &std::collections::HashMap<String, String>,
1643) -> Option<GradleCatalogEntry> {
1644 if raw_value.starts_with('"') && raw_value.ends_with('"') {
1645 let notation = strip_quotes(raw_value);
1646 let mut parts = notation.split(':');
1647 let namespace = truncate_field(parts.next()?.to_string());
1648 let name = truncate_field(parts.next()?.to_string());
1649 let version = parts.next().map(|v| truncate_field(v.to_string()));
1650 return Some(GradleCatalogEntry {
1651 namespace,
1652 name,
1653 version,
1654 });
1655 }
1656
1657 if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1658 return None;
1659 }
1660
1661 let inner = &raw_value[1..raw_value.len() - 1];
1662 let mut fields = std::collections::HashMap::new();
1663 for pair in inner.split(',').take(MAX_ITERATION_COUNT) {
1664 let Some((key, value)) = pair.split_once('=') else {
1665 continue;
1666 };
1667 fields.insert(
1668 truncate_field(key.trim().to_string()),
1669 truncate_field(strip_quotes(value.trim()).to_string()),
1670 );
1671 }
1672
1673 let (namespace, name) = if let Some(module) = fields.get("module") {
1674 let (group, artifact) = module.split_once(':')?;
1675 (
1676 truncate_field(group.to_string()),
1677 truncate_field(artifact.to_string()),
1678 )
1679 } else {
1680 (
1681 truncate_field(fields.get("group")?.to_string()),
1682 truncate_field(fields.get("name")?.to_string()),
1683 )
1684 };
1685
1686 let version = if let Some(version) = fields.get("version") {
1687 Some(truncate_field(version.to_string()))
1688 } else if let Some(version_ref) = fields.get("version.ref") {
1689 versions.get(version_ref).cloned().map(truncate_field)
1690 } else {
1691 None
1692 };
1693
1694 Some(GradleCatalogEntry {
1695 namespace,
1696 name,
1697 version,
1698 })
1699}
1700
1701fn strip_quotes(value: &str) -> &str {
1702 value
1703 .strip_prefix('"')
1704 .and_then(|v| v.strip_suffix('"'))
1705 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1706 .unwrap_or(value)
1707}
1708
1709fn extract_gradle_license_metadata(
1710 tokens: &[Tok],
1711) -> (
1712 Option<String>,
1713 Option<String>,
1714 Option<String>,
1715 Vec<crate::models::LicenseDetection>,
1716) {
1717 let mut i = 0;
1718 while i < tokens.len() {
1719 if let Tok::Ident(name) = &tokens[i]
1720 && name == "licenses"
1721 && i + 1 < tokens.len()
1722 && tokens[i + 1] == Tok::OpenBrace
1723 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1724 {
1725 let inner = &tokens[i + 2..block_end];
1726 if let Some((license_name, license_url)) = parse_license_block(inner) {
1727 let extracted =
1728 format_gradle_license_statement(&license_name, license_url.as_deref());
1729 let declared_candidate =
1730 derive_gradle_license_expression(&license_name, license_url.as_deref());
1731 if let Some(declared_candidate) = declared_candidate
1732 && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1733 {
1734 let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1735 let (declared, declared_spdx, detections) = build_declared_license_data(
1736 normalized,
1737 DeclaredLicenseMatchMetadata::single_line(matched_text),
1738 );
1739 return (
1740 extracted.map(truncate_field),
1741 declared.map(truncate_field),
1742 declared_spdx.map(truncate_field),
1743 detections,
1744 );
1745 }
1746
1747 return (
1748 extracted.map(truncate_field),
1749 None,
1750 None,
1751 empty_declared_license_data().2,
1752 );
1753 }
1754 i = block_end + 1;
1755 continue;
1756 }
1757 i += 1;
1758 }
1759
1760 (None, None, None, Vec::new())
1761}
1762
1763fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1764 let mut i = 0;
1765 while i < tokens.len() {
1766 if let Tok::Ident(name) = &tokens[i]
1767 && name == "license"
1768 && i + 1 < tokens.len()
1769 && tokens[i + 1] == Tok::OpenBrace
1770 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1771 {
1772 let mut license_name = None;
1773 let mut license_url = None;
1774 let block = &tokens[i + 2..block_end];
1775 let mut j = 0;
1776 while j < block.len() {
1777 if let Tok::Ident(label) = &block[j] {
1778 let normalized = label.strip_suffix(".set").unwrap_or(label);
1779 if (normalized == "name" || normalized == "url")
1780 && let Some(value) = next_string_literal(block, j + 1)
1781 {
1782 if normalized == "name" {
1783 license_name = Some(value);
1784 } else {
1785 license_url = Some(value);
1786 }
1787 }
1788 }
1789 j += 1;
1790 }
1791
1792 return license_name.map(|name| (name, license_url));
1793 }
1794 i += 1;
1795 }
1796 None
1797}
1798
1799fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1800 for token in tokens.iter().skip(start) {
1801 match token {
1802 Tok::Str(value) => return Some(truncate_field(value.clone())),
1803 Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1804 Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1805 _ => break,
1806 }
1807 }
1808 None
1809}
1810
1811fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1812 if tokens.get(start) != Some(&Tok::OpenBrace) {
1813 return None;
1814 }
1815 let mut depth = 1;
1816 let mut i = start + 1;
1817 while i < tokens.len() && depth > 0 {
1818 match &tokens[i] {
1819 Tok::OpenBrace => {
1820 depth += 1;
1821 if depth > MAX_RECURSION_DEPTH {
1822 warn!(
1823 "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1824 MAX_RECURSION_DEPTH
1825 );
1826 break;
1827 }
1828 }
1829 Tok::CloseBrace => depth -= 1,
1830 _ => {}
1831 }
1832 if depth == 0 {
1833 return Some(i);
1834 }
1835 i += 1;
1836 }
1837 None
1838}
1839
1840fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
1841 let mut output = format!("- license:\n name: {name}\n");
1842 if let Some(url) = url {
1843 output.push_str(&format!(" url: {url}\n"));
1844 }
1845 Some(truncate_field(output))
1846}
1847
1848fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
1849 let trimmed = name.trim();
1850 let candidates = [trimmed, url.unwrap_or("")];
1851
1852 for candidate in candidates {
1853 let lower = candidate.to_ascii_lowercase();
1854 if trimmed == "Apache-2.0"
1855 || lower.contains("apache-2.0")
1856 || lower.contains("apache license, version 2.0")
1857 || lower.contains("apache.org/licenses/license-2.0")
1858 {
1859 return Some(truncate_field("Apache-2.0".to_string()));
1860 }
1861 if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
1862 return Some(truncate_field("MIT".to_string()));
1863 }
1864 if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
1865 return Some(truncate_field(trimmed.to_string()));
1866 }
1867 }
1868
1869 None
1870}
1871
1872crate::register_parser!(
1873 "Gradle build script",
1874 &["**/build.gradle", "**/build.gradle.kts"],
1875 "maven",
1876 "Java",
1877 Some("https://gradle.org/"),
1878);
1879
1880#[cfg(test)]
1881mod tests {
1882 use super::*;
1883 use tempfile::tempdir;
1884
1885 #[test]
1886 fn test_is_match() {
1887 assert!(GradleParser::is_match(Path::new("build.gradle")));
1888 assert!(GradleParser::is_match(Path::new("build.gradle.kts")));
1889 assert!(GradleParser::is_match(Path::new("project/build.gradle")));
1890 assert!(!GradleParser::is_match(Path::new("build.xml")));
1891 assert!(!GradleParser::is_match(Path::new("settings.gradle")));
1892 }
1893
1894 #[test]
1895 fn test_extract_simple_dependencies() {
1896 let content = r#"
1897dependencies {
1898 compile 'org.apache.commons:commons-text:1.1'
1899 testCompile 'junit:junit:4.12'
1900}
1901"#;
1902 let tokens = lex(content);
1903 let deps = extract_dependencies(&tokens);
1904 assert_eq!(deps.len(), 2);
1905
1906 let dep1 = &deps[0];
1907 assert_eq!(
1908 dep1.purl,
1909 Some("pkg:maven/org.apache.commons/commons-text@1.1".to_string())
1910 );
1911 assert_eq!(dep1.scope, Some("compile".to_string()));
1912 assert_eq!(dep1.is_runtime, Some(true));
1913 assert_eq!(dep1.is_pinned, Some(true));
1914
1915 let dep2 = &deps[1];
1916 assert_eq!(dep2.purl, Some("pkg:maven/junit/junit@4.12".to_string()));
1917 assert_eq!(dep2.scope, Some("testCompile".to_string()));
1918 assert_eq!(dep2.is_runtime, Some(false));
1919 assert_eq!(dep2.is_optional, Some(true));
1920 }
1921
1922 #[test]
1923 fn test_extract_parens_notation() {
1924 let content = r#"
1925dependencies {
1926 implementation("com.example:library:1.0.0")
1927 testImplementation("junit:junit:4.13")
1928}
1929"#;
1930 let tokens = lex(content);
1931 let deps = extract_dependencies(&tokens);
1932 assert_eq!(deps.len(), 2);
1933 assert_eq!(
1934 deps[0].purl,
1935 Some("pkg:maven/com.example/library@1.0.0".to_string())
1936 );
1937 }
1938
1939 #[test]
1940 fn test_extract_named_parameters() {
1941 let content = r#"
1942dependencies {
1943 api group: 'com.google.guava', name: 'guava', version: '30.1-jre'
1944}
1945"#;
1946 let tokens = lex(content);
1947 let deps = extract_dependencies(&tokens);
1948 assert_eq!(deps.len(), 1);
1949 assert_eq!(
1950 deps[0].purl,
1951 Some("pkg:maven/com.google.guava/guava@30.1-jre".to_string())
1952 );
1953 assert_eq!(deps[0].scope, Some("api".to_string()));
1954 }
1955
1956 #[test]
1957 fn test_multiple_dependency_blocks_all_parsed() {
1958 let content = r#"
1959dependencies {
1960 implementation 'org.scala-lang:scala-library:2.11.12'
1961}
1962
1963dependencies {
1964 implementation 'commons-collections:commons-collections:3.2.2'
1965 testImplementation 'junit:junit:4.13'
1966}
1967"#;
1968 let tokens = lex(content);
1969 let deps = extract_dependencies(&tokens);
1970 assert_eq!(deps.len(), 3);
1971 assert_eq!(
1972 deps[0].purl,
1973 Some("pkg:maven/org.scala-lang/scala-library@2.11.12".to_string())
1974 );
1975 assert_eq!(
1976 deps[1].purl,
1977 Some("pkg:maven/commons-collections/commons-collections@3.2.2".to_string())
1978 );
1979 assert_eq!(deps[2].purl, Some("pkg:maven/junit/junit@4.13".to_string()));
1980 assert_eq!(deps[2].scope, Some("testImplementation".to_string()));
1981 }
1982
1983 #[test]
1984 fn test_nested_dependency_blocks_all_parsed() {
1985 let content = r#"
1986buildscript {
1987 dependencies {
1988 classpath("org.eclipse.jgit:org.eclipse.jgit:$jgitVersion")
1989 }
1990}
1991
1992subprojects {
1993 dependencies {
1994 implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinPluginVersion")
1995 }
1996}
1997"#;
1998 let tokens = lex(content);
1999 let deps = extract_dependencies(&tokens);
2000
2001 assert_eq!(deps.len(), 2);
2002 assert_eq!(
2003 deps[0].purl,
2004 Some("pkg:maven/org.eclipse.jgit/org.eclipse.jgit@%24jgitVersion".to_string())
2005 );
2006 assert_eq!(deps[0].scope, Some("classpath".to_string()));
2007 assert_eq!(
2008 deps[1].purl,
2009 Some(
2010 "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib-jdk8@%24kotlinPluginVersion"
2011 .to_string()
2012 )
2013 );
2014 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2015 }
2016
2017 #[test]
2018 fn test_no_version() {
2019 let content = r#"
2020dependencies {
2021 compile 'org.example:library'
2022}
2023"#;
2024 let tokens = lex(content);
2025 let deps = extract_dependencies(&tokens);
2026 assert_eq!(deps.len(), 1);
2027 assert_eq!(deps[0].is_pinned, Some(false));
2028 assert_eq!(deps[0].extracted_requirement, Some("".to_string()));
2029 }
2030
2031 #[test]
2032 fn test_nested_function_calls() {
2033 let content = r#"
2034dependencies {
2035 implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.12.2"))
2036 testImplementation(platform("org.junit:junit-bom:5.7.2"))
2037}
2038"#;
2039 let tokens = lex(content);
2040 let deps = extract_dependencies(&tokens);
2041 assert_eq!(deps.len(), 2);
2042 assert_eq!(
2043 deps[0].purl,
2044 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2".to_string())
2045 );
2046 assert_eq!(deps[0].scope, Some("enforcedPlatform".to_string()));
2047 assert_eq!(deps[1].scope, Some("platform".to_string()));
2048 }
2049
2050 #[test]
2051 fn test_map_format() {
2052 let content = r#"
2053dependencies {
2054 runtimeOnly(
2055 [group: 'org.jacoco', name: 'org.jacoco.ant', version: '0.7.4.201502262128'],
2056 [group: 'org.jacoco', name: 'org.jacoco.agent', version: '0.7.4.201502262128']
2057 )
2058}
2059"#;
2060 let tokens = lex(content);
2061 let deps = extract_dependencies(&tokens);
2062 assert_eq!(deps.len(), 2);
2063 assert_eq!(deps[0].scope, Some("".to_string()));
2064 assert_eq!(
2065 deps[0].purl,
2066 Some("pkg:maven/org.jacoco.ant@0.7.4.201502262128".to_string())
2067 );
2068 }
2069
2070 #[test]
2071 fn test_bracket_map_dedupes_exact_string_overlap() {
2072 let content = r#"
2073dependencies {
2074 runtimeOnly 'org.springframework:spring-core:2.5',
2075 'org.springframework:spring-aop:2.5'
2076 runtimeOnly(
2077 [group: 'org.springframework', name: 'spring-core', version: '2.5'],
2078 [group: 'org.springframework', name: 'spring-aop', version: '2.5']
2079 )
2080}
2081"#;
2082
2083 let tokens = lex(content);
2084 let deps = extract_dependencies(&tokens);
2085 assert_eq!(deps.len(), 2);
2086 assert_eq!(
2087 deps[0].purl,
2088 Some("pkg:maven/org.springframework/spring-core@2.5".to_string())
2089 );
2090 assert_eq!(
2091 deps[1].purl,
2092 Some("pkg:maven/org.springframework/spring-aop@2.5".to_string())
2093 );
2094 }
2095
2096 #[test]
2097 fn test_malformed_string_stops_cascading_false_positives() {
2098 let content = r#"
2099dependencies {
2100 implementation "com.fasterxml.jackson:jackson-bom:2.12.2'
2101 implementation" com.fasterxml.jackson.core:jackson-core"
2102 testImplementation 'org.junit:junit-bom:5.7.2'"
2103 testImplementation "org.junit.platform:junit-platform-commons"
2104}
2105"#;
2106
2107 let tokens = lex(content);
2108 let deps = extract_dependencies(&tokens);
2109 assert_eq!(deps.len(), 1);
2110 assert_eq!(
2111 deps[0].purl,
2112 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2%27".to_string())
2113 );
2114 }
2115
2116 #[test]
2117 fn test_project_references() {
2118 let content = r#"
2119dependencies {
2120 implementation(project(":documentation"))
2121 implementation(project(":basics"))
2122}
2123"#;
2124 let tokens = lex(content);
2125 let deps = extract_dependencies(&tokens);
2126 assert_eq!(deps.len(), 2);
2127 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2128 assert_eq!(
2129 deps[0]
2130 .extra_data
2131 .as_ref()
2132 .and_then(|data| data.get("project_path"))
2133 .and_then(|value| value.as_str()),
2134 Some("documentation")
2135 );
2136 assert_eq!(deps[0].purl, Some("pkg:maven/documentation".to_string()));
2137 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2138 assert_eq!(
2139 deps[1]
2140 .extra_data
2141 .as_ref()
2142 .and_then(|data| data.get("project_path"))
2143 .and_then(|value| value.as_str()),
2144 Some("basics")
2145 );
2146 assert_eq!(deps[1].purl, Some("pkg:maven/basics".to_string()));
2147 }
2148
2149 #[test]
2150 fn test_nested_project_references_preserve_parent_path() {
2151 let content = r#"
2152dependencies {
2153 implementation(project(":libs:download"))
2154 implementation(project(":libs:index"))
2155}
2156"#;
2157 let tokens = lex(content);
2158 let deps = extract_dependencies(&tokens);
2159
2160 assert_eq!(deps.len(), 2);
2161 assert_eq!(deps[0].purl, Some("pkg:maven/libs/download".to_string()));
2162 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2163 assert_eq!(
2164 deps[0]
2165 .extra_data
2166 .as_ref()
2167 .and_then(|data| data.get("project_path"))
2168 .and_then(|value| value.as_str()),
2169 Some("libs:download")
2170 );
2171 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2172 assert_eq!(
2173 deps[1]
2174 .extra_data
2175 .as_ref()
2176 .and_then(|data| data.get("project_path"))
2177 .and_then(|value| value.as_str()),
2178 Some("libs:index")
2179 );
2180 assert_eq!(deps[1].purl, Some("pkg:maven/libs/index".to_string()));
2181 }
2182
2183 #[test]
2184 fn test_testimplementation_project_reference_is_not_runtime() {
2185 let content = r#"
2186dependencies {
2187 testImplementation project(':mockito-config')
2188}
2189"#;
2190 let tokens = lex(content);
2191 let deps = extract_dependencies(&tokens);
2192
2193 assert_eq!(deps.len(), 1);
2194 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2195 assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2196 assert_eq!(deps[0].is_runtime, Some(false));
2197 assert_eq!(deps[0].is_optional, Some(true));
2198 assert_eq!(
2199 deps[0]
2200 .extra_data
2201 .as_ref()
2202 .and_then(|data| data.get("project_path"))
2203 .and_then(|value| value.as_str()),
2204 Some("mockito-config")
2205 );
2206 }
2207
2208 #[test]
2209 fn test_unresolved_dotted_identifiers_are_ignored_but_project_refs_survive() {
2210 let content = r#"
2211dependencies {
2212 implementation Deps.AndroidX.core
2213 implementation Deps.AndroidX.androidxAnnotation
2214 testImplementation TestDeps.mockitoCore3
2215 testImplementation project(':mockito-config')
2216}
2217"#;
2218 let tokens = lex(content);
2219 let deps = extract_dependencies(&tokens);
2220
2221 assert_eq!(deps.len(), 1);
2222 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2223 assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2224 assert_eq!(deps[0].is_runtime, Some(false));
2225 assert_eq!(deps[0].is_optional, Some(true));
2226 assert_eq!(
2227 deps[0]
2228 .extra_data
2229 .as_ref()
2230 .and_then(|data| data.get("project_path"))
2231 .and_then(|value| value.as_str()),
2232 Some("mockito-config")
2233 );
2234 }
2235
2236 #[test]
2237 fn test_buildsrc_kotlin_constants_resolve_from_committed_files() {
2238 let temp_dir = tempdir().unwrap();
2239 let build_src_dir = temp_dir
2240 .path()
2241 .join("buildSrc/src/main/java/com/example/buildsrc");
2242 std::fs::create_dir_all(&build_src_dir).unwrap();
2243 std::fs::write(
2244 build_src_dir.join("GradleDeps.kt"),
2245 r#"
2246object GradleDeps {
2247 object Kotlin {
2248 const val version = "2.0.0"
2249 const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version"
2250 }
2251}
2252"#,
2253 )
2254 .unwrap();
2255 std::fs::write(
2256 build_src_dir.join("Deps.kt"),
2257 r#"
2258object Deps {
2259 object AndroidX {
2260 const val core = "androidx.core:core:1.15.0"
2261 }
2262
2263 object SoLoader {
2264 private const val version = "0.11.0"
2265 const val soloader = "com.facebook.soloader:soloader:$version"
2266 }
2267}
2268"#,
2269 )
2270 .unwrap();
2271 std::fs::write(
2272 build_src_dir.join("TestDeps.kt"),
2273 r#"
2274object TestDeps {
2275 const val junit = "junit:junit:4.13.2"
2276}
2277"#,
2278 )
2279 .unwrap();
2280
2281 let build_gradle = temp_dir.path().join("build.gradle");
2282 std::fs::write(
2283 &build_gradle,
2284 r#"
2285buildscript {
2286 dependencies {
2287 classpath GradleDeps.Kotlin.gradlePlugin
2288 }
2289}
2290
2291dependencies {
2292 implementation Deps.AndroidX.core
2293 implementation Deps.SoLoader.soloader
2294 implementation project(':fbcore')
2295 testImplementation(TestDeps.junit) {
2296 because 'exercise parenthesized symbolic refs'
2297 }
2298}
2299"#,
2300 )
2301 .unwrap();
2302
2303 let package_data = GradleParser::extract_first_package(&build_gradle);
2304
2305 assert_eq!(package_data.dependencies.len(), 5);
2306 assert!(package_data.dependencies.iter().any(|dependency| {
2307 dependency.purl.as_deref()
2308 == Some("pkg:maven/org.jetbrains.kotlin/kotlin-gradle-plugin@2.0.0")
2309 && dependency.scope.as_deref() == Some("classpath")
2310 }));
2311 assert!(package_data.dependencies.iter().any(|dependency| {
2312 dependency.purl.as_deref() == Some("pkg:maven/androidx.core/core@1.15.0")
2313 && dependency.scope.as_deref() == Some("implementation")
2314 }));
2315 assert!(package_data.dependencies.iter().any(|dependency| {
2316 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.soloader/soloader@0.11.0")
2317 && dependency.scope.as_deref() == Some("implementation")
2318 }));
2319 assert!(package_data.dependencies.iter().any(|dependency| {
2320 dependency.purl.as_deref() == Some("pkg:maven/fbcore")
2321 && dependency.scope.as_deref() == Some("implementation")
2322 }));
2323 assert!(package_data.dependencies.iter().any(|dependency| {
2324 dependency.purl.as_deref() == Some("pkg:maven/junit/junit@4.13.2")
2325 && dependency.scope.as_deref() == Some("testImplementation")
2326 && dependency.is_runtime == Some(false)
2327 && dependency.is_optional == Some(true)
2328 }));
2329 }
2330
2331 #[test]
2332 fn test_gradle_properties_and_local_assignments_resolve_interpolation() {
2333 let temp_dir = tempdir().unwrap();
2334 std::fs::write(
2335 temp_dir.path().join("gradle.properties"),
2336 "ktorVersion=2.3.10\nkotlinVersion=2.0.0\n",
2337 )
2338 .unwrap();
2339 let build_gradle = temp_dir.path().join("build.gradle.kts");
2340 std::fs::write(
2341 &build_gradle,
2342 r#"
2343val ktorVersion: String by project
2344val kotlinVersion = "2.1.0"
2345
2346dependencies {
2347 implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
2348 testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
2349}
2350"#,
2351 )
2352 .unwrap();
2353
2354 let package_data = GradleParser::extract_first_package(&build_gradle);
2355 assert_eq!(package_data.dependencies.len(), 2);
2356 assert!(package_data.dependencies.iter().any(|dependency| {
2357 dependency.purl.as_deref() == Some("pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@2.1.0")
2358 && dependency.extracted_requirement.as_deref() == Some("2.1.0")
2359 && dependency.scope.as_deref() == Some("implementation")
2360 }));
2361 assert!(package_data.dependencies.iter().any(|dependency| {
2362 dependency.purl.as_deref() == Some("pkg:maven/io.ktor/ktor-server-test-host@2.3.10")
2363 && dependency.extracted_requirement.as_deref() == Some("2.3.10")
2364 && dependency.scope.as_deref() == Some("testImplementation")
2365 }));
2366 }
2367
2368 #[test]
2369 fn test_compile_only_is_not_runtime() {
2370 let content = r#"
2371dependencies {
2372 compileOnly 'org.antlr:antlr:2.7.7'
2373 compileOnlyApi 'com.example:annotations:1.0.0'
2374 testCompileOnly 'junit:junit:4.13'
2375}
2376"#;
2377 let tokens = lex(content);
2378 let deps = extract_dependencies(&tokens);
2379
2380 assert_eq!(deps.len(), 3);
2381 assert_eq!(deps[0].scope, Some("compileOnly".to_string()));
2382 assert_eq!(deps[0].is_runtime, Some(false));
2383 assert_eq!(deps[0].is_optional, Some(false));
2384
2385 assert_eq!(deps[1].scope, Some("compileOnlyApi".to_string()));
2386 assert_eq!(deps[1].is_runtime, Some(false));
2387 assert_eq!(deps[1].is_optional, Some(false));
2388
2389 assert_eq!(deps[2].scope, Some("testCompileOnly".to_string()));
2390 assert_eq!(deps[2].is_runtime, Some(false));
2391 assert_eq!(deps[2].is_optional, Some(true));
2392 }
2393
2394 #[test]
2395 fn test_version_catalog_alias_resolution_from_libs_versions_toml() {
2396 let temp_dir = tempdir().unwrap();
2397 let gradle_dir = temp_dir.path().join("gradle");
2398 std::fs::create_dir_all(&gradle_dir).unwrap();
2399
2400 std::fs::write(
2401 gradle_dir.join("libs.versions.toml"),
2402 r#"
2403[versions]
2404androidxAppcompat = "1.7.0"
2405
2406[libraries]
2407androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2408guardianproject-panic = { group = "info.guardianproject", name = "panic", version = "1.0.0" }
2409"#,
2410 )
2411 .unwrap();
2412
2413 let build_gradle = temp_dir.path().join("build.gradle");
2414 std::fs::write(
2415 &build_gradle,
2416 r#"
2417dependencies {
2418 implementation libs.androidx.appcompat
2419 fullImplementation libs.guardianproject.panic
2420}
2421"#,
2422 )
2423 .unwrap();
2424
2425 let package_data = GradleParser::extract_first_package(&build_gradle);
2426
2427 assert_eq!(package_data.dependencies.len(), 2);
2428 assert_eq!(
2429 package_data.dependencies[0].purl,
2430 Some("pkg:maven/androidx.appcompat/appcompat@1.7.0".to_string())
2431 );
2432 assert_eq!(
2433 package_data.dependencies[0].scope,
2434 Some("implementation".to_string())
2435 );
2436 assert_eq!(
2437 package_data.dependencies[1].purl,
2438 Some("pkg:maven/info.guardianproject/panic@1.0.0".to_string())
2439 );
2440 assert_eq!(
2441 package_data.dependencies[1].scope,
2442 Some("fullImplementation".to_string())
2443 );
2444 }
2445
2446 #[test]
2447 fn test_extract_gradle_license_metadata_from_pom_block() {
2448 let content = r#"
2449plugins {
2450 id 'java-library'
2451 id 'maven'
2452}
2453
2454dependencies {
2455 api 'org.apache.commons:commons-text:1.1'
2456}
2457
2458configure(install.repositories.mavenInstaller) {
2459 pom.project {
2460 licenses {
2461 license {
2462 name 'The Apache License, Version 2.0'
2463 url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
2464 }
2465 }
2466 }
2467}
2468"#;
2469
2470 let temp_dir = tempdir().unwrap();
2471 let build_gradle = temp_dir.path().join("build.gradle");
2472 std::fs::write(&build_gradle, content).unwrap();
2473
2474 let package_data = GradleParser::extract_first_package(&build_gradle);
2475
2476 assert_eq!(
2477 package_data.extracted_license_statement,
2478 Some(
2479 "- license:\n name: The Apache License, Version 2.0\n url: http://www.apache.org/licenses/LICENSE-2.0.txt\n"
2480 .to_string()
2481 )
2482 );
2483 assert_eq!(
2484 package_data.declared_license_expression_spdx,
2485 Some("Apache-2.0".to_string())
2486 );
2487 }
2488
2489 #[test]
2490 fn test_parse_gradle_version_catalog_helper() {
2491 let temp_dir = tempdir().unwrap();
2492 let catalog_path = temp_dir.path().join("libs.versions.toml");
2493 std::fs::write(
2494 &catalog_path,
2495 r#"
2496[versions]
2497androidxAppcompat = "1.7.0"
2498
2499[libraries]
2500androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2501"#,
2502 )
2503 .unwrap();
2504
2505 let entries = parse_gradle_version_catalog(&catalog_path).unwrap();
2506 let entry = entries.get("androidx.appcompat").unwrap();
2507
2508 assert_eq!(entry.namespace, "androidx.appcompat");
2509 assert_eq!(entry.name, "appcompat");
2510 assert_eq!(entry.version.as_deref(), Some("1.7.0"));
2511 }
2512
2513 #[test]
2514 fn test_string_interpolation() {
2515 let content = r#"
2516dependencies {
2517 compile "com.amazonaws:aws-java-sdk-core:${awsVer}"
2518}
2519"#;
2520 let tokens = lex(content);
2521 let deps = extract_dependencies(&tokens);
2522 assert_eq!(deps.len(), 1);
2523 assert_eq!(deps[0].extracted_requirement, Some("${awsVer}".to_string()));
2524 assert_eq!(
2525 deps[0].purl,
2526 Some("pkg:maven/com.amazonaws/aws-java-sdk-core@%24%7BawsVer%7D".to_string())
2527 );
2528 }
2529
2530 #[test]
2531 fn test_multi_value_string_notation() {
2532 let content = r#"
2533dependencies {
2534 runtimeOnly 'org.springframework:spring-core:2.5',
2535 'org.springframework:spring-aop:2.5'
2536}
2537"#;
2538 let tokens = lex(content);
2539 let deps = extract_dependencies(&tokens);
2540 assert_eq!(deps.len(), 2);
2541 assert_eq!(deps[0].scope, Some("".to_string()));
2542 assert_eq!(deps[1].scope, Some("".to_string()));
2543 }
2544
2545 #[test]
2546 fn test_kotlin_quoted_scope_string_dependency_extracted() {
2547 let content = r#"
2548dependencies {
2549 "js"("jquery:jquery:3.2.1@js")
2550}
2551"#;
2552 let tokens = lex(content);
2553 let deps = extract_dependencies(&tokens);
2554 assert_eq!(deps.len(), 1);
2555 assert_eq!(deps[0].scope, Some("js".to_string()));
2556 assert_eq!(
2557 deps[0].purl,
2558 Some("pkg:maven/jquery/jquery@3.2.1%40js".to_string())
2559 );
2560 }
2561
2562 #[test]
2563 fn test_kotlin_quoted_scope_project_reference_extracted() {
2564 let content = r#"
2565subprojects {
2566 dependencies {
2567 "testImplementation"(project(":utils:test-utils"))
2568 }
2569}
2570"#;
2571 let tokens = lex(content);
2572 let deps = extract_dependencies(&tokens);
2573 assert_eq!(deps.len(), 1);
2574 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2575 assert_eq!(deps[0].purl, Some("pkg:maven/utils/test-utils".to_string()));
2576 assert_eq!(deps[0].is_runtime, Some(false));
2577 assert_eq!(deps[0].is_optional, Some(true));
2578 assert_eq!(
2579 deps[0]
2580 .extra_data
2581 .as_ref()
2582 .and_then(|data| data.get("project_path"))
2583 .and_then(|value| value.as_str()),
2584 Some("utils:test-utils")
2585 );
2586 }
2587
2588 #[test]
2589 fn test_kotlin_quoted_scope_string_dependency_with_closure_extracted() {
2590 let content = r#"
2591dependencies {
2592 "implementation"("com.badlogicgames.gdx:gdx-tools:1.14.0") {
2593 exclude("com.badlogicgames.gdx", "gdx-backend-lwjgl")
2594 }
2595}
2596"#;
2597 let tokens = lex(content);
2598 let deps = extract_dependencies(&tokens);
2599 assert_eq!(deps.len(), 1);
2600 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2601 assert_eq!(
2602 deps[0].purl,
2603 Some("pkg:maven/com.badlogicgames.gdx/gdx-tools@1.14.0".to_string())
2604 );
2605 }
2606
2607 #[test]
2608 fn test_closure_after_dependency() {
2609 let content = r#"
2610dependencies {
2611 runtimeOnly('org.hibernate:hibernate:3.0.5') {
2612 transitive = true
2613 }
2614}
2615"#;
2616 let tokens = lex(content);
2617 let deps = extract_dependencies(&tokens);
2618 assert_eq!(deps.len(), 1);
2619 assert_eq!(
2620 deps[0].purl,
2621 Some("pkg:maven/org.hibernate/hibernate@3.0.5".to_string())
2622 );
2623 assert_eq!(deps[0].scope, Some("runtimeOnly".to_string()));
2624 }
2625}