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