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 Some(build_src_dir) = find_build_src_dir(path) else {
1245 return;
1246 };
1247 let Some(constants) = load_build_src_constants(&build_src_dir) else {
1248 return;
1249 };
1250
1251 for raw in raw_dependencies.iter_mut() {
1252 let Some(symbolic_ref) = raw.symbolic_ref.as_deref() else {
1253 continue;
1254 };
1255
1256 let mut visiting = HashSet::new();
1257 let Some(resolved) = resolve_build_src_value(symbolic_ref, &constants, &mut visiting)
1258 else {
1259 continue;
1260 };
1261 if !resolved.contains(':') {
1262 continue;
1263 }
1264
1265 let resolved_dependency = parse_colon_string(&resolved, &raw.scope);
1266 raw.namespace = resolved_dependency.namespace;
1267 raw.name = resolved_dependency.name;
1268 raw.version = resolved_dependency.version;
1269 }
1270}
1271
1272fn find_build_src_dir(path: &Path) -> Option<PathBuf> {
1273 for ancestor in path.ancestors() {
1274 let build_src_dir = ancestor.join("buildSrc");
1275 if build_src_dir.is_dir() {
1276 return Some(build_src_dir);
1277 }
1278 }
1279 None
1280}
1281
1282fn load_build_src_constants(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1283 let cache = BUILD_SRC_CONSTANT_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1284 if let Ok(guard) = cache.lock()
1285 && let Some(cached) = guard.get(build_src_dir)
1286 {
1287 return cached.clone();
1288 }
1289
1290 let parsed = parse_build_src_constants_dir(build_src_dir);
1291
1292 if let Ok(mut guard) = cache.lock() {
1293 guard.insert(build_src_dir.to_path_buf(), parsed.clone());
1294 }
1295
1296 parsed
1297}
1298
1299fn parse_build_src_constants_dir(build_src_dir: &Path) -> Option<BuildSrcConstMap> {
1300 let mut kotlin_files = Vec::new();
1301 for source_dir in [
1302 build_src_dir.join("src").join("main").join("java"),
1303 build_src_dir.join("src").join("main").join("kotlin"),
1304 ] {
1305 collect_build_src_kotlin_files(&source_dir, &mut kotlin_files);
1306 }
1307
1308 if kotlin_files.is_empty() {
1309 return None;
1310 }
1311
1312 let mut constants = HashMap::new();
1313 for file in kotlin_files.into_iter().take(MAX_ITERATION_COUNT) {
1314 let Ok(content) = read_file_to_string(&file, None) else {
1315 continue;
1316 };
1317 constants.extend(parse_build_src_constants(&content));
1318 }
1319
1320 (!constants.is_empty()).then_some(constants)
1321}
1322
1323fn collect_build_src_kotlin_files(dir: &Path, files: &mut Vec<PathBuf>) {
1324 if files.len() >= MAX_ITERATION_COUNT || !dir.is_dir() {
1325 return;
1326 }
1327
1328 let Ok(entries) = std::fs::read_dir(dir) else {
1329 return;
1330 };
1331
1332 for entry in entries.flatten().take(MAX_ITERATION_COUNT) {
1333 if files.len() >= MAX_ITERATION_COUNT {
1334 break;
1335 }
1336
1337 let path = entry.path();
1338 if path.is_dir() {
1339 collect_build_src_kotlin_files(&path, files);
1340 continue;
1341 }
1342
1343 if path.extension().is_some_and(|ext| ext == "kt") {
1344 files.push(path);
1345 }
1346 }
1347}
1348
1349fn parse_build_src_constants(content: &str) -> BuildSrcConstMap {
1350 let tokens = lex(content);
1351 let mut constants = HashMap::new();
1352 let mut object_stack = Vec::new();
1353 let mut brace_stack: Vec<Option<String>> = Vec::new();
1354 let mut i = 0;
1355
1356 while i < tokens.len() && i < MAX_ITERATION_COUNT {
1357 if let Some((name, consumed)) = parse_object_declaration(&tokens[i..]) {
1358 object_stack.push(name.clone());
1359 brace_stack.push(Some(name));
1360 i += consumed;
1361 continue;
1362 }
1363
1364 if let Some((name, expr, consumed)) = parse_build_src_const_definition(&tokens[i..]) {
1365 let scope = object_stack.join(".");
1366 let full_name = if scope.is_empty() {
1367 name.clone()
1368 } else {
1369 format!("{scope}.{name}")
1370 };
1371 constants.insert(
1372 truncate_field(full_name),
1373 BuildSrcConst {
1374 scope: truncate_field(scope),
1375 expr,
1376 },
1377 );
1378 i += consumed;
1379 continue;
1380 }
1381
1382 match &tokens[i] {
1383 Tok::OpenBrace => brace_stack.push(None),
1384 Tok::CloseBrace => {
1385 if let Some(marker) = brace_stack.pop()
1386 && marker.is_some()
1387 {
1388 object_stack.pop();
1389 }
1390 }
1391 _ => {}
1392 }
1393
1394 i += 1;
1395 }
1396
1397 constants
1398}
1399
1400fn parse_object_declaration(tokens: &[Tok]) -> Option<(String, usize)> {
1401 if let [Tok::Ident(keyword), Tok::Ident(name), Tok::OpenBrace, ..] = tokens
1402 && keyword == "object"
1403 {
1404 return Some((truncate_field(name.clone()), 3));
1405 }
1406 None
1407}
1408
1409fn parse_build_src_const_definition(tokens: &[Tok]) -> Option<(String, BuildSrcExpr, usize)> {
1410 let mut cursor = 0;
1411
1412 while let Some(Tok::Ident(modifier)) = tokens.get(cursor) {
1413 if matches!(
1414 modifier.as_str(),
1415 "private" | "internal" | "public" | "protected"
1416 ) {
1417 cursor += 1;
1418 continue;
1419 }
1420 break;
1421 }
1422
1423 if !matches!(tokens.get(cursor), Some(Tok::Ident(keyword)) if keyword == "const")
1424 || !matches!(tokens.get(cursor + 1), Some(Tok::Ident(keyword)) if keyword == "val")
1425 {
1426 return None;
1427 }
1428
1429 let Tok::Ident(name) = tokens.get(cursor + 2)? else {
1430 return None;
1431 };
1432 if tokens.get(cursor + 3) != Some(&Tok::Equals) {
1433 return None;
1434 }
1435
1436 let expr = match tokens.get(cursor + 4)? {
1437 Tok::Str(value) => BuildSrcExpr::Literal(truncate_field(value.clone())),
1438 Tok::Ident(value) => BuildSrcExpr::Ref(truncate_field(value.clone())),
1439 _ => return None,
1440 };
1441
1442 Some((truncate_field(name.clone()), expr, cursor + 5))
1443}
1444
1445fn resolve_build_src_value(
1446 key: &str,
1447 constants: &BuildSrcConstMap,
1448 visiting: &mut HashSet<String>,
1449) -> Option<String> {
1450 if !visiting.insert(key.to_string()) {
1451 return None;
1452 }
1453
1454 let resolved = constants
1455 .get(key)
1456 .and_then(|constant| resolve_build_src_expr(constant, constants, visiting));
1457 visiting.remove(key);
1458 resolved
1459}
1460
1461fn resolve_build_src_expr(
1462 constant: &BuildSrcConst,
1463 constants: &BuildSrcConstMap,
1464 visiting: &mut HashSet<String>,
1465) -> Option<String> {
1466 match &constant.expr {
1467 BuildSrcExpr::Literal(value) => Some(interpolate_build_src_string(
1468 value,
1469 &constant.scope,
1470 constants,
1471 visiting,
1472 )),
1473 BuildSrcExpr::Ref(reference) => {
1474 resolve_build_src_symbol(&constant.scope, reference, constants, visiting)
1475 }
1476 }
1477}
1478
1479fn resolve_build_src_symbol(
1480 scope: &str,
1481 reference: &str,
1482 constants: &BuildSrcConstMap,
1483 visiting: &mut HashSet<String>,
1484) -> Option<String> {
1485 if reference.contains('.') {
1486 return resolve_build_src_value(reference, constants, visiting);
1487 }
1488
1489 let mut current_scope = Some(scope);
1490 while let Some(scope_name) = current_scope {
1491 if !scope_name.is_empty() {
1492 let candidate = format!("{scope_name}.{reference}");
1493 if let Some(value) = resolve_build_src_value(&candidate, constants, visiting) {
1494 return Some(value);
1495 }
1496 }
1497
1498 current_scope = scope_name.rsplit_once('.').map(|(parent, _)| parent);
1499 }
1500
1501 resolve_build_src_value(reference, constants, visiting)
1502}
1503
1504fn interpolate_build_src_string(
1505 value: &str,
1506 scope: &str,
1507 constants: &BuildSrcConstMap,
1508 visiting: &mut HashSet<String>,
1509) -> String {
1510 let chars = value.chars().collect::<Vec<_>>();
1511 let mut rendered = String::new();
1512 let mut i = 0;
1513
1514 while i < chars.len() {
1515 if chars[i] != '$' {
1516 rendered.push(chars[i]);
1517 i += 1;
1518 continue;
1519 }
1520
1521 if i + 1 >= chars.len() {
1522 rendered.push(chars[i]);
1523 break;
1524 }
1525
1526 if chars[i + 1] == '{' {
1527 let start = i;
1528 i += 2;
1529 let mut reference = String::new();
1530 while i < chars.len() && chars[i] != '}' {
1531 reference.push(chars[i]);
1532 i += 1;
1533 }
1534 if i < chars.len() && chars[i] == '}' {
1535 i += 1;
1536 }
1537
1538 if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting)
1539 {
1540 rendered.push_str(&resolved);
1541 } else {
1542 rendered.push_str(&value[start..i]);
1543 }
1544 continue;
1545 }
1546
1547 let start = i;
1548 i += 1;
1549 let mut reference = String::new();
1550 while i < chars.len() && matches!(chars[i], 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '.') {
1551 reference.push(chars[i]);
1552 i += 1;
1553 }
1554
1555 if reference.is_empty() {
1556 rendered.push('$');
1557 continue;
1558 }
1559
1560 if let Some(resolved) = resolve_build_src_symbol(scope, &reference, constants, visiting) {
1561 rendered.push_str(&resolved);
1562 } else {
1563 rendered.push_str(&value[start..i]);
1564 }
1565 }
1566
1567 truncate_field(rendered)
1568}
1569
1570#[derive(Debug, Clone)]
1571struct GradleCatalogEntry {
1572 namespace: String,
1573 name: String,
1574 version: Option<String>,
1575}
1576
1577fn resolve_gradle_version_catalog_aliases(path: &Path, dependencies: &mut [Dependency]) {
1578 let Some(catalog_path) = find_gradle_version_catalog(path) else {
1579 return;
1580 };
1581 let Some(entries) = parse_gradle_version_catalog(&catalog_path) else {
1582 return;
1583 };
1584
1585 for dep in dependencies.iter_mut() {
1586 let alias = dep
1587 .extra_data
1588 .as_ref()
1589 .and_then(|data| data.get("catalog_alias"))
1590 .and_then(|value| value.as_str());
1591 let Some(alias) = alias else {
1592 continue;
1593 };
1594 let Some(entry) = entries.get(alias) else {
1595 continue;
1596 };
1597
1598 let mut purl = PackageUrl::new("maven", &entry.name).ok();
1599 if let Some(ref mut purl) = purl {
1600 if !entry.namespace.is_empty() {
1601 let _ = purl.with_namespace(&entry.namespace);
1602 }
1603 if let Some(version) = &entry.version {
1604 let _ = purl.with_version(version);
1605 }
1606 }
1607
1608 dep.purl = purl.map(|p| truncate_field(p.to_string()));
1609 dep.extracted_requirement = entry.version.as_ref().map(|v| truncate_field(v.clone()));
1610 dep.is_pinned = Some(entry.version.is_some());
1611 }
1612}
1613
1614fn find_gradle_version_catalog(path: &Path) -> Option<std::path::PathBuf> {
1615 for ancestor in path.ancestors() {
1616 let nested = ancestor.join("gradle").join("libs.versions.toml");
1617 if nested.is_file() {
1618 return Some(nested);
1619 }
1620
1621 let sibling = ancestor.join("libs.versions.toml");
1622 if sibling.is_file() {
1623 return Some(sibling);
1624 }
1625 }
1626
1627 None
1628}
1629
1630fn parse_gradle_version_catalog(
1631 path: &Path,
1632) -> Option<std::collections::HashMap<String, GradleCatalogEntry>> {
1633 let content = read_file_to_string(path, None).ok()?;
1634 let mut section = "";
1635 let mut versions = std::collections::HashMap::new();
1636 let mut libraries = std::collections::HashMap::new();
1637
1638 for line in content.lines().take(MAX_ITERATION_COUNT) {
1639 let trimmed = line.split('#').next().unwrap_or("").trim();
1640 if trimmed.is_empty() {
1641 continue;
1642 }
1643
1644 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1645 section = trimmed.trim_matches(&['[', ']'][..]);
1646 continue;
1647 }
1648
1649 let Some((key, value)) = trimmed.split_once('=') else {
1650 continue;
1651 };
1652 let key = key.trim().to_string();
1653 let value = value.trim().to_string();
1654
1655 match section {
1656 "versions" => {
1657 versions.insert(key, truncate_field(strip_quotes(&value).to_string()));
1658 }
1659 "libraries" => {
1660 libraries.insert(key, value);
1661 }
1662 _ => {}
1663 }
1664 }
1665
1666 let mut result = std::collections::HashMap::new();
1667 for (alias, raw_value) in libraries.into_iter().take(MAX_ITERATION_COUNT) {
1668 let Some(entry) = parse_gradle_catalog_entry(&raw_value, &versions) else {
1669 continue;
1670 };
1671 result.insert(truncate_field(alias.replace('-', ".")), entry);
1672 }
1673
1674 Some(result)
1675}
1676
1677fn parse_gradle_catalog_entry(
1678 raw_value: &str,
1679 versions: &std::collections::HashMap<String, String>,
1680) -> Option<GradleCatalogEntry> {
1681 if raw_value.starts_with('"') && raw_value.ends_with('"') {
1682 let notation = strip_quotes(raw_value);
1683 let mut parts = notation.split(':');
1684 let namespace = truncate_field(parts.next()?.to_string());
1685 let name = truncate_field(parts.next()?.to_string());
1686 let version = parts.next().map(|v| truncate_field(v.to_string()));
1687 return Some(GradleCatalogEntry {
1688 namespace,
1689 name,
1690 version,
1691 });
1692 }
1693
1694 if !(raw_value.starts_with('{') && raw_value.ends_with('}')) {
1695 return None;
1696 }
1697
1698 let inner = &raw_value[1..raw_value.len() - 1];
1699 let mut fields = std::collections::HashMap::new();
1700 for pair in inner.split(',').take(MAX_ITERATION_COUNT) {
1701 let Some((key, value)) = pair.split_once('=') else {
1702 continue;
1703 };
1704 fields.insert(
1705 truncate_field(key.trim().to_string()),
1706 truncate_field(strip_quotes(value.trim()).to_string()),
1707 );
1708 }
1709
1710 let (namespace, name) = if let Some(module) = fields.get("module") {
1711 let (group, artifact) = module.split_once(':')?;
1712 (
1713 truncate_field(group.to_string()),
1714 truncate_field(artifact.to_string()),
1715 )
1716 } else {
1717 (
1718 truncate_field(fields.get("group")?.to_string()),
1719 truncate_field(fields.get("name")?.to_string()),
1720 )
1721 };
1722
1723 let version = if let Some(version) = fields.get("version") {
1724 Some(truncate_field(version.to_string()))
1725 } else if let Some(version_ref) = fields.get("version.ref") {
1726 versions.get(version_ref).cloned().map(truncate_field)
1727 } else {
1728 None
1729 };
1730
1731 Some(GradleCatalogEntry {
1732 namespace,
1733 name,
1734 version,
1735 })
1736}
1737
1738fn strip_quotes(value: &str) -> &str {
1739 value
1740 .strip_prefix('"')
1741 .and_then(|v| v.strip_suffix('"'))
1742 .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
1743 .unwrap_or(value)
1744}
1745
1746fn extract_gradle_license_metadata(
1747 tokens: &[Tok],
1748) -> (
1749 Option<String>,
1750 Option<String>,
1751 Option<String>,
1752 Vec<crate::models::LicenseDetection>,
1753) {
1754 let mut i = 0;
1755 while i < tokens.len() {
1756 if let Tok::Ident(name) = &tokens[i]
1757 && name == "licenses"
1758 && i + 1 < tokens.len()
1759 && tokens[i + 1] == Tok::OpenBrace
1760 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1761 {
1762 let inner = &tokens[i + 2..block_end];
1763 if let Some((license_name, license_url)) = parse_license_block(inner) {
1764 let extracted =
1765 format_gradle_license_statement(&license_name, license_url.as_deref());
1766 let declared_candidate =
1767 derive_gradle_license_expression(&license_name, license_url.as_deref());
1768 if let Some(declared_candidate) = declared_candidate
1769 && let Some(normalized) = normalize_spdx_expression(&declared_candidate)
1770 {
1771 let matched_text = extracted.as_deref().unwrap_or(&declared_candidate);
1772 let (declared, declared_spdx, detections) = build_declared_license_data(
1773 normalized,
1774 DeclaredLicenseMatchMetadata::single_line(matched_text),
1775 );
1776 return (
1777 extracted.map(truncate_field),
1778 declared.map(truncate_field),
1779 declared_spdx.map(truncate_field),
1780 detections,
1781 );
1782 }
1783
1784 return (
1785 extracted.map(truncate_field),
1786 None,
1787 None,
1788 empty_declared_license_data().2,
1789 );
1790 }
1791 i = block_end + 1;
1792 continue;
1793 }
1794 i += 1;
1795 }
1796
1797 (None, None, None, Vec::new())
1798}
1799
1800fn parse_license_block(tokens: &[Tok]) -> Option<(String, Option<String>)> {
1801 let mut i = 0;
1802 while i < tokens.len() {
1803 if let Tok::Ident(name) = &tokens[i]
1804 && name == "license"
1805 && i + 1 < tokens.len()
1806 && tokens[i + 1] == Tok::OpenBrace
1807 && let Some(block_end) = find_matching_brace(tokens, i + 1)
1808 {
1809 let mut license_name = None;
1810 let mut license_url = None;
1811 let block = &tokens[i + 2..block_end];
1812 let mut j = 0;
1813 while j < block.len() {
1814 if let Tok::Ident(label) = &block[j] {
1815 let normalized = label.strip_suffix(".set").unwrap_or(label);
1816 if (normalized == "name" || normalized == "url")
1817 && let Some(value) = next_string_literal(block, j + 1)
1818 {
1819 if normalized == "name" {
1820 license_name = Some(value);
1821 } else {
1822 license_url = Some(value);
1823 }
1824 }
1825 }
1826 j += 1;
1827 }
1828
1829 return license_name.map(|name| (name, license_url));
1830 }
1831 i += 1;
1832 }
1833 None
1834}
1835
1836fn next_string_literal(tokens: &[Tok], start: usize) -> Option<String> {
1837 for token in tokens.iter().skip(start) {
1838 match token {
1839 Tok::Str(value) => return Some(truncate_field(value.clone())),
1840 Tok::MalformedStr(value) => return Some(truncate_field(value.clone())),
1841 Tok::Ident(_) | Tok::Colon | Tok::Equals | Tok::OpenParen | Tok::CloseParen => continue,
1842 _ => break,
1843 }
1844 }
1845 None
1846}
1847
1848fn find_matching_brace(tokens: &[Tok], start: usize) -> Option<usize> {
1849 if tokens.get(start) != Some(&Tok::OpenBrace) {
1850 return None;
1851 }
1852 let mut depth = 1;
1853 let mut i = start + 1;
1854 while i < tokens.len() && depth > 0 {
1855 match &tokens[i] {
1856 Tok::OpenBrace => {
1857 depth += 1;
1858 if depth > MAX_RECURSION_DEPTH {
1859 warn!(
1860 "Gradle parser: nesting depth exceeded {} in find_matching_brace",
1861 MAX_RECURSION_DEPTH
1862 );
1863 break;
1864 }
1865 }
1866 Tok::CloseBrace => depth -= 1,
1867 _ => {}
1868 }
1869 if depth == 0 {
1870 return Some(i);
1871 }
1872 i += 1;
1873 }
1874 None
1875}
1876
1877fn format_gradle_license_statement(name: &str, url: Option<&str>) -> Option<String> {
1878 let mut output = format!("- license:\n name: {name}\n");
1879 if let Some(url) = url {
1880 output.push_str(&format!(" url: {url}\n"));
1881 }
1882 Some(truncate_field(output))
1883}
1884
1885fn derive_gradle_license_expression(name: &str, url: Option<&str>) -> Option<String> {
1886 let trimmed = name.trim();
1887 let candidates = [trimmed, url.unwrap_or("")];
1888
1889 for candidate in candidates {
1890 let lower = candidate.to_ascii_lowercase();
1891 if trimmed == "Apache-2.0"
1892 || lower.contains("apache-2.0")
1893 || lower.contains("apache license, version 2.0")
1894 || lower.contains("apache.org/licenses/license-2.0")
1895 {
1896 return Some(truncate_field("Apache-2.0".to_string()));
1897 }
1898 if trimmed == "MIT" || lower.contains("opensource.org/licenses/mit") {
1899 return Some(truncate_field("MIT".to_string()));
1900 }
1901 if trimmed == "BSD-2-Clause" || trimmed == "BSD-3-Clause" {
1902 return Some(truncate_field(trimmed.to_string()));
1903 }
1904 }
1905
1906 None
1907}
1908
1909crate::register_parser!(
1910 "Gradle build script",
1911 &["**/build.gradle", "**/build.gradle.kts"],
1912 "maven",
1913 "Java",
1914 Some("https://gradle.org/"),
1915);
1916
1917#[cfg(test)]
1918mod tests {
1919 use super::*;
1920 use tempfile::tempdir;
1921
1922 #[test]
1923 fn test_is_match() {
1924 assert!(GradleParser::is_match(Path::new("build.gradle")));
1925 assert!(GradleParser::is_match(Path::new("build.gradle.kts")));
1926 assert!(GradleParser::is_match(Path::new("project/build.gradle")));
1927 assert!(!GradleParser::is_match(Path::new("build.xml")));
1928 assert!(!GradleParser::is_match(Path::new("settings.gradle")));
1929 }
1930
1931 #[test]
1932 fn test_extract_simple_dependencies() {
1933 let content = r#"
1934dependencies {
1935 compile 'org.apache.commons:commons-text:1.1'
1936 testCompile 'junit:junit:4.12'
1937}
1938"#;
1939 let tokens = lex(content);
1940 let deps = extract_dependencies(&tokens);
1941 assert_eq!(deps.len(), 2);
1942
1943 let dep1 = &deps[0];
1944 assert_eq!(
1945 dep1.purl,
1946 Some("pkg:maven/org.apache.commons/commons-text@1.1".to_string())
1947 );
1948 assert_eq!(dep1.scope, Some("compile".to_string()));
1949 assert_eq!(dep1.is_runtime, Some(true));
1950 assert_eq!(dep1.is_pinned, Some(true));
1951
1952 let dep2 = &deps[1];
1953 assert_eq!(dep2.purl, Some("pkg:maven/junit/junit@4.12".to_string()));
1954 assert_eq!(dep2.scope, Some("testCompile".to_string()));
1955 assert_eq!(dep2.is_runtime, Some(false));
1956 assert_eq!(dep2.is_optional, Some(true));
1957 }
1958
1959 #[test]
1960 fn test_extract_parens_notation() {
1961 let content = r#"
1962dependencies {
1963 implementation("com.example:library:1.0.0")
1964 testImplementation("junit:junit:4.13")
1965}
1966"#;
1967 let tokens = lex(content);
1968 let deps = extract_dependencies(&tokens);
1969 assert_eq!(deps.len(), 2);
1970 assert_eq!(
1971 deps[0].purl,
1972 Some("pkg:maven/com.example/library@1.0.0".to_string())
1973 );
1974 }
1975
1976 #[test]
1977 fn test_extract_named_parameters() {
1978 let content = r#"
1979dependencies {
1980 api group: 'com.google.guava', name: 'guava', version: '30.1-jre'
1981}
1982"#;
1983 let tokens = lex(content);
1984 let deps = extract_dependencies(&tokens);
1985 assert_eq!(deps.len(), 1);
1986 assert_eq!(
1987 deps[0].purl,
1988 Some("pkg:maven/com.google.guava/guava@30.1-jre".to_string())
1989 );
1990 assert_eq!(deps[0].scope, Some("api".to_string()));
1991 }
1992
1993 #[test]
1994 fn test_multiple_dependency_blocks_all_parsed() {
1995 let content = r#"
1996dependencies {
1997 implementation 'org.scala-lang:scala-library:2.11.12'
1998}
1999
2000dependencies {
2001 implementation 'commons-collections:commons-collections:3.2.2'
2002 testImplementation 'junit:junit:4.13'
2003}
2004"#;
2005 let tokens = lex(content);
2006 let deps = extract_dependencies(&tokens);
2007 assert_eq!(deps.len(), 3);
2008 assert_eq!(
2009 deps[0].purl,
2010 Some("pkg:maven/org.scala-lang/scala-library@2.11.12".to_string())
2011 );
2012 assert_eq!(
2013 deps[1].purl,
2014 Some("pkg:maven/commons-collections/commons-collections@3.2.2".to_string())
2015 );
2016 assert_eq!(deps[2].purl, Some("pkg:maven/junit/junit@4.13".to_string()));
2017 assert_eq!(deps[2].scope, Some("testImplementation".to_string()));
2018 }
2019
2020 #[test]
2021 fn test_nested_dependency_blocks_all_parsed() {
2022 let content = r#"
2023buildscript {
2024 dependencies {
2025 classpath("org.eclipse.jgit:org.eclipse.jgit:$jgitVersion")
2026 }
2027}
2028
2029subprojects {
2030 dependencies {
2031 implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinPluginVersion")
2032 }
2033}
2034"#;
2035 let tokens = lex(content);
2036 let deps = extract_dependencies(&tokens);
2037
2038 assert_eq!(deps.len(), 2);
2039 assert_eq!(
2040 deps[0].purl,
2041 Some("pkg:maven/org.eclipse.jgit/org.eclipse.jgit@%24jgitVersion".to_string())
2042 );
2043 assert_eq!(deps[0].scope, Some("classpath".to_string()));
2044 assert_eq!(
2045 deps[1].purl,
2046 Some(
2047 "pkg:maven/org.jetbrains.kotlin/kotlin-stdlib-jdk8@%24kotlinPluginVersion"
2048 .to_string()
2049 )
2050 );
2051 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2052 }
2053
2054 #[test]
2055 fn test_no_version() {
2056 let content = r#"
2057dependencies {
2058 compile 'org.example:library'
2059}
2060"#;
2061 let tokens = lex(content);
2062 let deps = extract_dependencies(&tokens);
2063 assert_eq!(deps.len(), 1);
2064 assert_eq!(deps[0].is_pinned, Some(false));
2065 assert_eq!(deps[0].extracted_requirement, Some("".to_string()));
2066 }
2067
2068 #[test]
2069 fn test_nested_function_calls() {
2070 let content = r#"
2071dependencies {
2072 implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.12.2"))
2073 testImplementation(platform("org.junit:junit-bom:5.7.2"))
2074}
2075"#;
2076 let tokens = lex(content);
2077 let deps = extract_dependencies(&tokens);
2078 assert_eq!(deps.len(), 2);
2079 assert_eq!(
2080 deps[0].purl,
2081 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2".to_string())
2082 );
2083 assert_eq!(deps[0].scope, Some("enforcedPlatform".to_string()));
2084 assert_eq!(deps[1].scope, Some("platform".to_string()));
2085 }
2086
2087 #[test]
2088 fn test_map_format() {
2089 let content = r#"
2090dependencies {
2091 runtimeOnly(
2092 [group: 'org.jacoco', name: 'org.jacoco.ant', version: '0.7.4.201502262128'],
2093 [group: 'org.jacoco', name: 'org.jacoco.agent', version: '0.7.4.201502262128']
2094 )
2095}
2096"#;
2097 let tokens = lex(content);
2098 let deps = extract_dependencies(&tokens);
2099 assert_eq!(deps.len(), 2);
2100 assert_eq!(deps[0].scope, Some("".to_string()));
2101 assert_eq!(
2102 deps[0].purl,
2103 Some("pkg:maven/org.jacoco.ant@0.7.4.201502262128".to_string())
2104 );
2105 }
2106
2107 #[test]
2108 fn test_bracket_map_dedupes_exact_string_overlap() {
2109 let content = r#"
2110dependencies {
2111 runtimeOnly 'org.springframework:spring-core:2.5',
2112 'org.springframework:spring-aop:2.5'
2113 runtimeOnly(
2114 [group: 'org.springframework', name: 'spring-core', version: '2.5'],
2115 [group: 'org.springframework', name: 'spring-aop', version: '2.5']
2116 )
2117}
2118"#;
2119
2120 let tokens = lex(content);
2121 let deps = extract_dependencies(&tokens);
2122 assert_eq!(deps.len(), 2);
2123 assert_eq!(
2124 deps[0].purl,
2125 Some("pkg:maven/org.springframework/spring-core@2.5".to_string())
2126 );
2127 assert_eq!(
2128 deps[1].purl,
2129 Some("pkg:maven/org.springframework/spring-aop@2.5".to_string())
2130 );
2131 }
2132
2133 #[test]
2134 fn test_malformed_string_stops_cascading_false_positives() {
2135 let content = r#"
2136dependencies {
2137 implementation "com.fasterxml.jackson:jackson-bom:2.12.2'
2138 implementation" com.fasterxml.jackson.core:jackson-core"
2139 testImplementation 'org.junit:junit-bom:5.7.2'"
2140 testImplementation "org.junit.platform:junit-platform-commons"
2141}
2142"#;
2143
2144 let tokens = lex(content);
2145 let deps = extract_dependencies(&tokens);
2146 assert_eq!(deps.len(), 1);
2147 assert_eq!(
2148 deps[0].purl,
2149 Some("pkg:maven/com.fasterxml.jackson/jackson-bom@2.12.2%27".to_string())
2150 );
2151 }
2152
2153 #[test]
2154 fn test_project_references() {
2155 let content = r#"
2156dependencies {
2157 implementation(project(":documentation"))
2158 implementation(project(":basics"))
2159}
2160"#;
2161 let tokens = lex(content);
2162 let deps = extract_dependencies(&tokens);
2163 assert_eq!(deps.len(), 2);
2164 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2165 assert_eq!(
2166 deps[0]
2167 .extra_data
2168 .as_ref()
2169 .and_then(|data| data.get("project_path"))
2170 .and_then(|value| value.as_str()),
2171 Some("documentation")
2172 );
2173 assert_eq!(deps[0].purl, Some("pkg:maven/documentation".to_string()));
2174 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2175 assert_eq!(
2176 deps[1]
2177 .extra_data
2178 .as_ref()
2179 .and_then(|data| data.get("project_path"))
2180 .and_then(|value| value.as_str()),
2181 Some("basics")
2182 );
2183 assert_eq!(deps[1].purl, Some("pkg:maven/basics".to_string()));
2184 }
2185
2186 #[test]
2187 fn test_nested_project_references_preserve_parent_path() {
2188 let content = r#"
2189dependencies {
2190 implementation(project(":libs:download"))
2191 implementation(project(":libs:index"))
2192}
2193"#;
2194 let tokens = lex(content);
2195 let deps = extract_dependencies(&tokens);
2196
2197 assert_eq!(deps.len(), 2);
2198 assert_eq!(deps[0].purl, Some("pkg:maven/libs/download".to_string()));
2199 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2200 assert_eq!(
2201 deps[0]
2202 .extra_data
2203 .as_ref()
2204 .and_then(|data| data.get("project_path"))
2205 .and_then(|value| value.as_str()),
2206 Some("libs:download")
2207 );
2208 assert_eq!(deps[1].scope, Some("implementation".to_string()));
2209 assert_eq!(
2210 deps[1]
2211 .extra_data
2212 .as_ref()
2213 .and_then(|data| data.get("project_path"))
2214 .and_then(|value| value.as_str()),
2215 Some("libs:index")
2216 );
2217 assert_eq!(deps[1].purl, Some("pkg:maven/libs/index".to_string()));
2218 }
2219
2220 #[test]
2221 fn test_testimplementation_project_reference_is_not_runtime() {
2222 let content = r#"
2223dependencies {
2224 testImplementation project(':mockito-config')
2225}
2226"#;
2227 let tokens = lex(content);
2228 let deps = extract_dependencies(&tokens);
2229
2230 assert_eq!(deps.len(), 1);
2231 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2232 assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2233 assert_eq!(deps[0].is_runtime, Some(false));
2234 assert_eq!(deps[0].is_optional, Some(true));
2235 assert_eq!(
2236 deps[0]
2237 .extra_data
2238 .as_ref()
2239 .and_then(|data| data.get("project_path"))
2240 .and_then(|value| value.as_str()),
2241 Some("mockito-config")
2242 );
2243 }
2244
2245 #[test]
2246 fn test_unresolved_dotted_identifiers_are_ignored_but_project_refs_survive() {
2247 let content = r#"
2248dependencies {
2249 implementation Deps.AndroidX.core
2250 implementation Deps.AndroidX.androidxAnnotation
2251 testImplementation TestDeps.mockitoCore3
2252 testImplementation project(':mockito-config')
2253}
2254"#;
2255 let tokens = lex(content);
2256 let deps = extract_dependencies(&tokens);
2257
2258 assert_eq!(deps.len(), 1);
2259 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2260 assert_eq!(deps[0].purl, Some("pkg:maven/mockito-config".to_string()));
2261 assert_eq!(deps[0].is_runtime, Some(false));
2262 assert_eq!(deps[0].is_optional, Some(true));
2263 assert_eq!(
2264 deps[0]
2265 .extra_data
2266 .as_ref()
2267 .and_then(|data| data.get("project_path"))
2268 .and_then(|value| value.as_str()),
2269 Some("mockito-config")
2270 );
2271 }
2272
2273 #[test]
2274 fn test_buildsrc_kotlin_constants_resolve_from_committed_files() {
2275 let temp_dir = tempdir().unwrap();
2276 let build_src_dir = temp_dir
2277 .path()
2278 .join("buildSrc/src/main/java/com/example/buildsrc");
2279 std::fs::create_dir_all(&build_src_dir).unwrap();
2280 std::fs::write(
2281 build_src_dir.join("GradleDeps.kt"),
2282 r#"
2283object GradleDeps {
2284 object Kotlin {
2285 const val version = "2.0.0"
2286 const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$version"
2287 }
2288}
2289"#,
2290 )
2291 .unwrap();
2292 std::fs::write(
2293 build_src_dir.join("Deps.kt"),
2294 r#"
2295object Deps {
2296 object AndroidX {
2297 const val core = "androidx.core:core:1.15.0"
2298 }
2299
2300 object SoLoader {
2301 private const val version = "0.11.0"
2302 const val soloader = "com.facebook.soloader:soloader:$version"
2303 }
2304}
2305"#,
2306 )
2307 .unwrap();
2308 std::fs::write(
2309 build_src_dir.join("TestDeps.kt"),
2310 r#"
2311object TestDeps {
2312 const val junit = "junit:junit:4.13.2"
2313}
2314"#,
2315 )
2316 .unwrap();
2317
2318 let build_gradle = temp_dir.path().join("build.gradle");
2319 std::fs::write(
2320 &build_gradle,
2321 r#"
2322buildscript {
2323 dependencies {
2324 classpath GradleDeps.Kotlin.gradlePlugin
2325 }
2326}
2327
2328dependencies {
2329 implementation Deps.AndroidX.core
2330 implementation Deps.SoLoader.soloader
2331 implementation project(':fbcore')
2332 testImplementation(TestDeps.junit) {
2333 because 'exercise parenthesized symbolic refs'
2334 }
2335}
2336"#,
2337 )
2338 .unwrap();
2339
2340 let package_data = GradleParser::extract_first_package(&build_gradle);
2341
2342 assert_eq!(package_data.dependencies.len(), 5);
2343 assert!(package_data.dependencies.iter().any(|dependency| {
2344 dependency.purl.as_deref()
2345 == Some("pkg:maven/org.jetbrains.kotlin/kotlin-gradle-plugin@2.0.0")
2346 && dependency.scope.as_deref() == Some("classpath")
2347 }));
2348 assert!(package_data.dependencies.iter().any(|dependency| {
2349 dependency.purl.as_deref() == Some("pkg:maven/androidx.core/core@1.15.0")
2350 && dependency.scope.as_deref() == Some("implementation")
2351 }));
2352 assert!(package_data.dependencies.iter().any(|dependency| {
2353 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.soloader/soloader@0.11.0")
2354 && dependency.scope.as_deref() == Some("implementation")
2355 }));
2356 assert!(package_data.dependencies.iter().any(|dependency| {
2357 dependency.purl.as_deref() == Some("pkg:maven/fbcore")
2358 && dependency.scope.as_deref() == Some("implementation")
2359 }));
2360 assert!(package_data.dependencies.iter().any(|dependency| {
2361 dependency.purl.as_deref() == Some("pkg:maven/junit/junit@4.13.2")
2362 && dependency.scope.as_deref() == Some("testImplementation")
2363 && dependency.is_runtime == Some(false)
2364 && dependency.is_optional == Some(true)
2365 }));
2366 }
2367
2368 #[test]
2369 fn test_gradle_properties_and_local_assignments_resolve_interpolation() {
2370 let temp_dir = tempdir().unwrap();
2371 std::fs::write(
2372 temp_dir.path().join("gradle.properties"),
2373 "ktorVersion=2.3.10\nkotlinVersion=2.0.0\n",
2374 )
2375 .unwrap();
2376 let build_gradle = temp_dir.path().join("build.gradle.kts");
2377 std::fs::write(
2378 &build_gradle,
2379 r#"
2380val ktorVersion: String by project
2381val kotlinVersion = "2.1.0"
2382
2383dependencies {
2384 implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
2385 testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
2386}
2387"#,
2388 )
2389 .unwrap();
2390
2391 let package_data = GradleParser::extract_first_package(&build_gradle);
2392 assert_eq!(package_data.dependencies.len(), 2);
2393 assert!(package_data.dependencies.iter().any(|dependency| {
2394 dependency.purl.as_deref() == Some("pkg:maven/org.jetbrains.kotlin/kotlin-stdlib@2.1.0")
2395 && dependency.extracted_requirement.as_deref() == Some("2.1.0")
2396 && dependency.scope.as_deref() == Some("implementation")
2397 }));
2398 assert!(package_data.dependencies.iter().any(|dependency| {
2399 dependency.purl.as_deref() == Some("pkg:maven/io.ktor/ktor-server-test-host@2.3.10")
2400 && dependency.extracted_requirement.as_deref() == Some("2.3.10")
2401 && dependency.scope.as_deref() == Some("testImplementation")
2402 }));
2403 }
2404
2405 #[test]
2406 fn test_conditional_dependencies_inside_if_blocks_are_extracted() {
2407 let temp_dir = tempdir().unwrap();
2408 let build_gradle = temp_dir.path().join("build.gradle");
2409 std::fs::write(
2410 &build_gradle,
2411 r#"
2412def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
2413
2414dependencies {
2415 implementation("com.facebook.react:react-android")
2416
2417 if (hermesEnabled.toBoolean()) {
2418 implementation("com.facebook.react:hermes-android")
2419 } else {
2420 implementation jscFlavor
2421 }
2422}
2423"#,
2424 )
2425 .unwrap();
2426
2427 let package_data = GradleParser::extract_first_package(&build_gradle);
2428
2429 assert!(package_data.dependencies.iter().any(|dependency| {
2430 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.react/react-android")
2431 && dependency.scope.as_deref() == Some("implementation")
2432 }));
2433 assert!(package_data.dependencies.iter().any(|dependency| {
2434 dependency.purl.as_deref() == Some("pkg:maven/com.facebook.react/hermes-android")
2435 && dependency.scope.as_deref() == Some("implementation")
2436 }));
2437 }
2438
2439 #[test]
2440 fn test_compile_only_is_not_runtime() {
2441 let content = r#"
2442dependencies {
2443 compileOnly 'org.antlr:antlr:2.7.7'
2444 compileOnlyApi 'com.example:annotations:1.0.0'
2445 testCompileOnly 'junit:junit:4.13'
2446}
2447"#;
2448 let tokens = lex(content);
2449 let deps = extract_dependencies(&tokens);
2450
2451 assert_eq!(deps.len(), 3);
2452 assert_eq!(deps[0].scope, Some("compileOnly".to_string()));
2453 assert_eq!(deps[0].is_runtime, Some(false));
2454 assert_eq!(deps[0].is_optional, Some(false));
2455
2456 assert_eq!(deps[1].scope, Some("compileOnlyApi".to_string()));
2457 assert_eq!(deps[1].is_runtime, Some(false));
2458 assert_eq!(deps[1].is_optional, Some(false));
2459
2460 assert_eq!(deps[2].scope, Some("testCompileOnly".to_string()));
2461 assert_eq!(deps[2].is_runtime, Some(false));
2462 assert_eq!(deps[2].is_optional, Some(true));
2463 }
2464
2465 #[test]
2466 fn test_version_catalog_alias_resolution_from_libs_versions_toml() {
2467 let temp_dir = tempdir().unwrap();
2468 let gradle_dir = temp_dir.path().join("gradle");
2469 std::fs::create_dir_all(&gradle_dir).unwrap();
2470
2471 std::fs::write(
2472 gradle_dir.join("libs.versions.toml"),
2473 r#"
2474[versions]
2475androidxAppcompat = "1.7.0"
2476
2477[libraries]
2478androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2479guardianproject-panic = { group = "info.guardianproject", name = "panic", version = "1.0.0" }
2480"#,
2481 )
2482 .unwrap();
2483
2484 let build_gradle = temp_dir.path().join("build.gradle");
2485 std::fs::write(
2486 &build_gradle,
2487 r#"
2488dependencies {
2489 implementation libs.androidx.appcompat
2490 fullImplementation libs.guardianproject.panic
2491}
2492"#,
2493 )
2494 .unwrap();
2495
2496 let package_data = GradleParser::extract_first_package(&build_gradle);
2497
2498 assert_eq!(package_data.dependencies.len(), 2);
2499 assert_eq!(
2500 package_data.dependencies[0].purl,
2501 Some("pkg:maven/androidx.appcompat/appcompat@1.7.0".to_string())
2502 );
2503 assert_eq!(
2504 package_data.dependencies[0].scope,
2505 Some("implementation".to_string())
2506 );
2507 assert_eq!(
2508 package_data.dependencies[1].purl,
2509 Some("pkg:maven/info.guardianproject/panic@1.0.0".to_string())
2510 );
2511 assert_eq!(
2512 package_data.dependencies[1].scope,
2513 Some("fullImplementation".to_string())
2514 );
2515 }
2516
2517 #[test]
2518 fn test_extract_gradle_license_metadata_from_pom_block() {
2519 let content = r#"
2520plugins {
2521 id 'java-library'
2522 id 'maven'
2523}
2524
2525dependencies {
2526 api 'org.apache.commons:commons-text:1.1'
2527}
2528
2529configure(install.repositories.mavenInstaller) {
2530 pom.project {
2531 licenses {
2532 license {
2533 name 'The Apache License, Version 2.0'
2534 url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
2535 }
2536 }
2537 }
2538}
2539"#;
2540
2541 let temp_dir = tempdir().unwrap();
2542 let build_gradle = temp_dir.path().join("build.gradle");
2543 std::fs::write(&build_gradle, content).unwrap();
2544
2545 let package_data = GradleParser::extract_first_package(&build_gradle);
2546
2547 assert_eq!(
2548 package_data.extracted_license_statement,
2549 Some(
2550 "- license:\n name: The Apache License, Version 2.0\n url: http://www.apache.org/licenses/LICENSE-2.0.txt\n"
2551 .to_string()
2552 )
2553 );
2554 assert_eq!(
2555 package_data.declared_license_expression_spdx,
2556 Some("Apache-2.0".to_string())
2557 );
2558 }
2559
2560 #[test]
2561 fn test_parse_gradle_version_catalog_helper() {
2562 let temp_dir = tempdir().unwrap();
2563 let catalog_path = temp_dir.path().join("libs.versions.toml");
2564 std::fs::write(
2565 &catalog_path,
2566 r#"
2567[versions]
2568androidxAppcompat = "1.7.0"
2569
2570[libraries]
2571androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppcompat" }
2572"#,
2573 )
2574 .unwrap();
2575
2576 let entries = parse_gradle_version_catalog(&catalog_path).unwrap();
2577 let entry = entries.get("androidx.appcompat").unwrap();
2578
2579 assert_eq!(entry.namespace, "androidx.appcompat");
2580 assert_eq!(entry.name, "appcompat");
2581 assert_eq!(entry.version.as_deref(), Some("1.7.0"));
2582 }
2583
2584 #[test]
2585 fn test_string_interpolation() {
2586 let content = r#"
2587dependencies {
2588 compile "com.amazonaws:aws-java-sdk-core:${awsVer}"
2589}
2590"#;
2591 let tokens = lex(content);
2592 let deps = extract_dependencies(&tokens);
2593 assert_eq!(deps.len(), 1);
2594 assert_eq!(deps[0].extracted_requirement, Some("${awsVer}".to_string()));
2595 assert_eq!(
2596 deps[0].purl,
2597 Some("pkg:maven/com.amazonaws/aws-java-sdk-core@%24%7BawsVer%7D".to_string())
2598 );
2599 }
2600
2601 #[test]
2602 fn test_multi_value_string_notation() {
2603 let content = r#"
2604dependencies {
2605 runtimeOnly 'org.springframework:spring-core:2.5',
2606 'org.springframework:spring-aop:2.5'
2607}
2608"#;
2609 let tokens = lex(content);
2610 let deps = extract_dependencies(&tokens);
2611 assert_eq!(deps.len(), 2);
2612 assert_eq!(deps[0].scope, Some("".to_string()));
2613 assert_eq!(deps[1].scope, Some("".to_string()));
2614 }
2615
2616 #[test]
2617 fn test_kotlin_quoted_scope_string_dependency_extracted() {
2618 let content = r#"
2619dependencies {
2620 "js"("jquery:jquery:3.2.1@js")
2621}
2622"#;
2623 let tokens = lex(content);
2624 let deps = extract_dependencies(&tokens);
2625 assert_eq!(deps.len(), 1);
2626 assert_eq!(deps[0].scope, Some("js".to_string()));
2627 assert_eq!(
2628 deps[0].purl,
2629 Some("pkg:maven/jquery/jquery@3.2.1%40js".to_string())
2630 );
2631 }
2632
2633 #[test]
2634 fn test_kotlin_quoted_scope_project_reference_extracted() {
2635 let content = r#"
2636subprojects {
2637 dependencies {
2638 "testImplementation"(project(":utils:test-utils"))
2639 }
2640}
2641"#;
2642 let tokens = lex(content);
2643 let deps = extract_dependencies(&tokens);
2644 assert_eq!(deps.len(), 1);
2645 assert_eq!(deps[0].scope, Some("testImplementation".to_string()));
2646 assert_eq!(deps[0].purl, Some("pkg:maven/utils/test-utils".to_string()));
2647 assert_eq!(deps[0].is_runtime, Some(false));
2648 assert_eq!(deps[0].is_optional, Some(true));
2649 assert_eq!(
2650 deps[0]
2651 .extra_data
2652 .as_ref()
2653 .and_then(|data| data.get("project_path"))
2654 .and_then(|value| value.as_str()),
2655 Some("utils:test-utils")
2656 );
2657 }
2658
2659 #[test]
2660 fn test_kotlin_quoted_scope_string_dependency_with_closure_extracted() {
2661 let content = r#"
2662dependencies {
2663 "implementation"("com.badlogicgames.gdx:gdx-tools:1.14.0") {
2664 exclude("com.badlogicgames.gdx", "gdx-backend-lwjgl")
2665 }
2666}
2667"#;
2668 let tokens = lex(content);
2669 let deps = extract_dependencies(&tokens);
2670 assert_eq!(deps.len(), 1);
2671 assert_eq!(deps[0].scope, Some("implementation".to_string()));
2672 assert_eq!(
2673 deps[0].purl,
2674 Some("pkg:maven/com.badlogicgames.gdx/gdx-tools@1.14.0".to_string())
2675 );
2676 }
2677
2678 #[test]
2679 fn test_closure_after_dependency() {
2680 let content = r#"
2681dependencies {
2682 runtimeOnly('org.hibernate:hibernate:3.0.5') {
2683 transitive = true
2684 }
2685}
2686"#;
2687 let tokens = lex(content);
2688 let deps = extract_dependencies(&tokens);
2689 assert_eq!(deps.len(), 1);
2690 assert_eq!(
2691 deps[0].purl,
2692 Some("pkg:maven/org.hibernate/hibernate@3.0.5".to_string())
2693 );
2694 assert_eq!(deps[0].scope, Some("runtimeOnly".to_string()));
2695 }
2696}