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