1use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::json;
10
11use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
12
13use super::PackageParser;
14use super::utils::{MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, truncate_field};
15
16pub struct SbtParser;
17
18impl PackageParser for SbtParser {
19 const PACKAGE_TYPE: PackageType = PackageType::Maven;
20
21 fn is_match(path: &Path) -> bool {
22 path.file_name().is_some_and(|name| name == "build.sbt")
23 }
24
25 fn extract_packages(path: &Path) -> Vec<PackageData> {
26 let content = match read_file_to_string(path, None) {
27 Ok(content) => content,
28 Err(error) => {
29 warn!("Failed to read {:?}: {}", path, error);
30 return vec![default_package_data()];
31 }
32 };
33
34 let sanitized = strip_comments(&content);
35 let statements = split_top_level_statements(&sanitized);
36 let aliases = resolve_string_aliases(&statements);
37 let parsed = parse_statements(&statements, &aliases);
38
39 let homepage_url = parsed.homepage.or(parsed.organization_homepage);
40 let extracted_license_statement = format_license_entries(&parsed.licenses);
41 let purl = build_maven_purl(
42 parsed.organization.as_deref(),
43 parsed.name.as_deref(),
44 parsed.version.as_deref(),
45 );
46
47 vec![PackageData {
48 package_type: Some(Self::PACKAGE_TYPE),
49 primary_language: Some("Scala".to_string()),
50 namespace: parsed.organization.map(truncate_field),
51 name: parsed.name.map(truncate_field),
52 version: parsed.version.map(truncate_field),
53 description: parsed.description.map(truncate_field),
54 homepage_url: homepage_url.map(truncate_field),
55 extracted_license_statement: extracted_license_statement.map(truncate_field),
56 dependencies: parsed.dependencies,
57 datasource_id: Some(DatasourceId::SbtBuildSbt),
58 purl,
59 ..Default::default()
60 }]
61 }
62
63 fn metadata() -> Vec<super::metadata::ParserMetadata> {
64 vec![super::metadata::ParserMetadata {
65 description: "Scala SBT build.sbt definition",
66 file_patterns: &["**/build.sbt"],
67 package_type: "maven",
68 primary_language: "Scala",
69 documentation_url: Some("https://www.scala-sbt.org/1.x/docs/Basic-Def.html"),
70 }]
71 }
72}
73
74fn default_package_data() -> PackageData {
75 PackageData {
76 package_type: Some(SbtParser::PACKAGE_TYPE),
77 primary_language: Some("Scala".to_string()),
78 datasource_id: Some(DatasourceId::SbtBuildSbt),
79 ..Default::default()
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84enum Token {
85 Ident(String),
86 Str(String),
87 Symbol(&'static str),
88}
89
90#[derive(Debug, Clone)]
91enum AliasExpr {
92 Literal(String),
93 Reference(String),
94}
95
96#[derive(Debug, Clone)]
97struct ScopedValue {
98 precedence: u8,
99 value: String,
100}
101
102#[derive(Debug, Clone)]
103struct LicenseEntry {
104 name: String,
105 url: String,
106}
107
108#[derive(Debug, Default)]
109struct ParsedSbtData {
110 organization: Option<String>,
111 name: Option<String>,
112 version: Option<String>,
113 description: Option<String>,
114 homepage: Option<String>,
115 organization_homepage: Option<String>,
116 licenses: Vec<LicenseEntry>,
117 dependencies: Vec<Dependency>,
118}
119
120#[derive(Default)]
121struct SbtParseAccumulator {
122 organization: Option<ScopedValue>,
123 name: Option<ScopedValue>,
124 version: Option<ScopedValue>,
125 description: Option<ScopedValue>,
126 homepage: Option<ScopedValue>,
127 organization_homepage: Option<ScopedValue>,
128 licenses: Vec<LicenseEntry>,
129 dependencies: Vec<Dependency>,
130}
131
132fn strip_comments(input: &str) -> String {
133 let chars: Vec<char> = input.chars().collect();
134 let mut output = String::with_capacity(input.len());
135 let mut index = 0;
136 let mut in_string = false;
137 let mut escaped = false;
138
139 while index < chars.len() {
140 let ch = chars[index];
141
142 if in_string {
143 output.push(ch);
144 if escaped {
145 escaped = false;
146 } else if ch == '\\' {
147 escaped = true;
148 } else if ch == '"' {
149 in_string = false;
150 }
151 index += 1;
152 continue;
153 }
154
155 if ch == '"' {
156 in_string = true;
157 output.push(ch);
158 index += 1;
159 continue;
160 }
161
162 if ch == '/' && chars.get(index + 1) == Some(&'/') {
163 index += 2;
164 while index < chars.len() && chars[index] != '\n' {
165 index += 1;
166 }
167 continue;
168 }
169
170 if ch == '/' && chars.get(index + 1) == Some(&'*') {
171 index += 2;
172 while index + 1 < chars.len() {
173 if chars[index] == '*' && chars[index + 1] == '/' {
174 index += 2;
175 break;
176 }
177 if chars[index] == '\n' {
178 output.push('\n');
179 }
180 index += 1;
181 }
182 continue;
183 }
184
185 output.push(ch);
186 index += 1;
187 }
188
189 output
190}
191
192fn split_top_level_statements(input: &str) -> Vec<String> {
193 let mut statements = Vec::new();
194 let mut current = String::new();
195 let mut paren_depth = 0usize;
196 let mut bracket_depth = 0usize;
197 let mut brace_depth = 0usize;
198 let mut in_string = false;
199 let mut escaped = false;
200 let mut iterations = 0usize;
201
202 for ch in input.chars() {
203 iterations += 1;
204 if iterations > MAX_ITERATION_COUNT {
205 break;
206 }
207
208 if in_string {
209 current.push(ch);
210 if escaped {
211 escaped = false;
212 } else if ch == '\\' {
213 escaped = true;
214 } else if ch == '"' {
215 in_string = false;
216 }
217 continue;
218 }
219
220 match ch {
221 '"' => {
222 in_string = true;
223 current.push(ch);
224 }
225 '(' => {
226 paren_depth += 1;
227 current.push(ch);
228 }
229 ')' => {
230 paren_depth = paren_depth.saturating_sub(1);
231 current.push(ch);
232 }
233 '[' => {
234 bracket_depth += 1;
235 current.push(ch);
236 }
237 ']' => {
238 bracket_depth = bracket_depth.saturating_sub(1);
239 current.push(ch);
240 }
241 '{' => {
242 brace_depth += 1;
243 current.push(ch);
244 }
245 '}' => {
246 brace_depth = brace_depth.saturating_sub(1);
247 current.push(ch);
248 }
249 '\n' | ';' if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => {
250 let trimmed = current.trim();
251 if !trimmed.is_empty() {
252 statements.push(trimmed.to_string());
253 }
254 current.clear();
255 }
256 _ => current.push(ch),
257 }
258 }
259
260 let trimmed = current.trim();
261 if !trimmed.is_empty() {
262 statements.push(trimmed.to_string());
263 }
264
265 statements
266}
267
268fn tokenize(statement: &str) -> Vec<Token> {
269 let chars: Vec<char> = statement.chars().collect();
270 let mut tokens = Vec::new();
271 let mut index = 0;
272 let mut iterations = 0usize;
273
274 while index < chars.len() {
275 iterations += 1;
276 if iterations > MAX_ITERATION_COUNT {
277 break;
278 }
279
280 let ch = chars[index];
281
282 if ch.is_whitespace() {
283 index += 1;
284 continue;
285 }
286
287 if ch == '"' {
288 index += 1;
289 let start = index;
290 let mut escaped = false;
291 while index < chars.len() {
292 let current = chars[index];
293 if escaped {
294 escaped = false;
295 } else if current == '\\' {
296 escaped = true;
297 } else if current == '"' {
298 break;
299 }
300 index += 1;
301 }
302
303 let value: String = chars[start..index].iter().collect();
304 tokens.push(Token::Str(value));
305 if index < chars.len() && chars[index] == '"' {
306 index += 1;
307 }
308 continue;
309 }
310
311 if matches_chars(&chars, index, &['+', '+', '=']) {
312 tokens.push(Token::Symbol("++="));
313 index += 3;
314 continue;
315 }
316
317 if matches_chars(&chars, index, &[':', '=']) {
318 tokens.push(Token::Symbol(":="));
319 index += 2;
320 continue;
321 }
322
323 if matches_chars(&chars, index, &['+', '=']) {
324 tokens.push(Token::Symbol("+="));
325 index += 2;
326 continue;
327 }
328
329 if matches_chars(&chars, index, &['%', '%']) {
330 tokens.push(Token::Symbol("%%"));
331 index += 2;
332 continue;
333 }
334
335 if matches_chars(&chars, index, &['-', '>']) {
336 tokens.push(Token::Symbol("->"));
337 index += 2;
338 continue;
339 }
340
341 match ch {
342 '%' => {
343 tokens.push(Token::Symbol("%"));
344 index += 1;
345 }
346 '/' => {
347 tokens.push(Token::Symbol("/"));
348 index += 1;
349 }
350 '=' => {
351 tokens.push(Token::Symbol("="));
352 index += 1;
353 }
354 '(' => {
355 tokens.push(Token::Symbol("("));
356 index += 1;
357 }
358 ')' => {
359 tokens.push(Token::Symbol(")"));
360 index += 1;
361 }
362 '[' => {
363 tokens.push(Token::Symbol("["));
364 index += 1;
365 }
366 ']' => {
367 tokens.push(Token::Symbol("]"));
368 index += 1;
369 }
370 '{' => {
371 tokens.push(Token::Symbol("{"));
372 index += 1;
373 }
374 '}' => {
375 tokens.push(Token::Symbol("}"));
376 index += 1;
377 }
378 ',' => {
379 tokens.push(Token::Symbol(","));
380 index += 1;
381 }
382 _ if is_ident_start(ch) => {
383 let start = index;
384 index += 1;
385 while index < chars.len() && is_ident_char(chars[index]) {
386 index += 1;
387 }
388 let value: String = chars[start..index].iter().collect();
389 tokens.push(Token::Ident(value));
390 }
391 _ => {
392 index += 1;
393 }
394 }
395 }
396
397 tokens
398}
399
400fn is_ident_start(ch: char) -> bool {
401 ch.is_ascii_alphabetic() || ch == '_'
402}
403
404fn is_ident_char(ch: char) -> bool {
405 ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'
406}
407
408fn matches_chars(chars: &[char], index: usize, expected: &[char]) -> bool {
409 chars.get(index..index + expected.len()) == Some(expected)
410}
411
412fn resolve_string_aliases(statements: &[String]) -> HashMap<String, String> {
413 let mut raw_aliases = HashMap::new();
414
415 for statement in statements.iter().take(MAX_ITERATION_COUNT) {
416 let tokens = tokenize(statement);
417 if let Some((name, expr)) = parse_alias_declaration(&tokens) {
418 raw_aliases.insert(name, expr);
419 }
420 }
421
422 let mut resolved = HashMap::new();
423 for name in raw_aliases.keys() {
424 let mut guard: RecursionGuard<String> = RecursionGuard::new();
425 if let Some(value) = resolve_alias_value(name, &raw_aliases, &mut resolved, &mut guard) {
426 resolved.insert(name.clone(), value);
427 }
428 }
429
430 resolved
431}
432
433fn parse_alias_declaration(tokens: &[Token]) -> Option<(String, AliasExpr)> {
434 match tokens {
435 [
436 Token::Ident(keyword),
437 Token::Ident(name),
438 Token::Symbol("="),
439 expr @ ..,
440 ] if keyword == "val" => {
441 if let [Token::Str(value)] = expr {
442 return Some((name.clone(), AliasExpr::Literal(value.clone())));
443 }
444 if let [Token::Ident(reference)] = expr {
445 return Some((name.clone(), AliasExpr::Reference(reference.clone())));
446 }
447 None
448 }
449 _ => None,
450 }
451}
452
453fn resolve_alias_value(
454 name: &str,
455 raw_aliases: &HashMap<String, AliasExpr>,
456 resolved: &mut HashMap<String, String>,
457 guard: &mut RecursionGuard<String>,
458) -> Option<String> {
459 if guard.exceeded() {
460 warn!(
461 "Recursion depth exceeded in alias resolution for '{}'",
462 name
463 );
464 return None;
465 }
466
467 if let Some(value) = resolved.get(name) {
468 return Some(value.clone());
469 }
470
471 if guard.enter(name.to_string()) {
472 return None;
473 }
474
475 let value = match raw_aliases.get(name)? {
476 AliasExpr::Literal(value) => Some(value.clone()),
477 AliasExpr::Reference(reference) => {
478 resolve_alias_value(reference, raw_aliases, resolved, guard)
479 }
480 };
481
482 guard.leave(name.to_string());
483 value
484}
485
486fn resolve_seq_aliases(statements: &[String]) -> HashMap<String, Vec<Vec<Token>>> {
487 let mut aliases = HashMap::new();
488
489 for statement in statements {
490 let tokens = tokenize(statement);
491 if let Some((name, items)) = parse_seq_alias_declaration(&tokens) {
492 aliases.insert(name, items);
493 }
494 }
495
496 aliases
497}
498
499fn parse_seq_alias_declaration(tokens: &[Token]) -> Option<(String, Vec<Vec<Token>>)> {
500 match tokens {
501 [
502 Token::Ident(keyword),
503 Token::Ident(name),
504 Token::Symbol("="),
505 expr @ ..,
506 ] if keyword == "val" => parse_seq_items(expr).map(|items| (name.clone(), items)),
507 _ => None,
508 }
509}
510
511fn parse_seq_items(tokens: &[Token]) -> Option<Vec<Vec<Token>>> {
512 let [
513 Token::Ident(seq),
514 Token::Symbol("("),
515 inner @ ..,
516 Token::Symbol(")"),
517 ] = tokens
518 else {
519 return None;
520 };
521 if seq != "Seq" {
522 return None;
523 }
524
525 Some(
526 split_by_top_level_commas(inner)
527 .into_iter()
528 .map(|item| item.to_vec())
529 .collect(),
530 )
531}
532
533fn parse_statements(statements: &[String], aliases: &HashMap<String, String>) -> ParsedSbtData {
534 let bundle_aliases = resolve_seq_aliases(statements);
535 let mut state = SbtParseAccumulator::default();
536
537 for statement in statements.iter().take(MAX_ITERATION_COUNT) {
538 let tokens = tokenize(statement);
539 process_statement_tokens(&tokens, aliases, &bundle_aliases, &mut state);
540 }
541
542 ParsedSbtData {
543 organization: state.organization.map(|value| value.value),
544 name: state.name.map(|value| value.value),
545 version: state.version.map(|value| value.value),
546 description: state.description.map(|value| value.value),
547 homepage: state.homepage.map(|value| value.value),
548 organization_homepage: state.organization_homepage.map(|value| value.value),
549 licenses: state.licenses,
550 dependencies: state.dependencies,
551 }
552}
553
554fn process_statement_tokens(
555 tokens: &[Token],
556 aliases: &HashMap<String, String>,
557 bundle_aliases: &HashMap<String, Vec<Vec<Token>>>,
558 state: &mut SbtParseAccumulator,
559) {
560 if let Some(inner_items) = extract_root_settings_items(tokens, bundle_aliases) {
561 for item in inner_items {
562 process_statement_tokens(&item, aliases, bundle_aliases, state);
563 }
564 return;
565 }
566
567 if let Some((precedence, value)) = parse_string_setting(tokens, aliases, "organization") {
568 set_scoped_value(&mut state.organization, precedence, value);
569 return;
570 }
571
572 if let Some((precedence, value)) = parse_string_setting(tokens, aliases, "name") {
573 set_scoped_value(&mut state.name, precedence, value);
574 return;
575 }
576
577 if let Some((precedence, value)) = parse_string_setting(tokens, aliases, "version") {
578 set_scoped_value(&mut state.version, precedence, value);
579 return;
580 }
581
582 if let Some((precedence, value)) = parse_string_setting(tokens, aliases, "description") {
583 set_scoped_value(&mut state.description, precedence, value);
584 return;
585 }
586
587 if let Some((precedence, value)) = parse_url_setting(tokens, "homepage") {
588 set_scoped_value(&mut state.homepage, precedence, value);
589 return;
590 }
591
592 if let Some((precedence, value)) = parse_url_setting(tokens, "organizationHomepage") {
593 set_scoped_value(&mut state.organization_homepage, precedence, value);
594 return;
595 }
596
597 if let Some(license_entry) = parse_license_append(tokens) {
598 state.licenses.push(license_entry);
599 return;
600 }
601
602 if let Some(new_dependencies) = parse_library_dependencies(tokens, aliases, bundle_aliases) {
603 state.dependencies.extend(new_dependencies);
604 }
605}
606
607fn set_scoped_value(target: &mut Option<ScopedValue>, precedence: u8, value: String) {
608 let should_replace = target
609 .as_ref()
610 .is_none_or(|current| precedence >= current.precedence);
611
612 if should_replace {
613 *target = Some(ScopedValue { precedence, value });
614 }
615}
616
617fn parse_setting_prefix(tokens: &[Token]) -> (u8, &[Token]) {
618 match tokens {
619 [Token::Ident(scope), Token::Symbol("/"), rest @ ..] if scope == "ThisBuild" => (1, rest),
620 _ => (2, tokens),
621 }
622}
623
624fn parse_string_setting(
625 tokens: &[Token],
626 aliases: &HashMap<String, String>,
627 key: &str,
628) -> Option<(u8, String)> {
629 let (precedence, rest) = parse_setting_prefix(tokens);
630 match rest {
631 [Token::Ident(name), Token::Symbol(":="), expr @ ..] if name == key => {
632 parse_literal_string_expr(expr, aliases).map(|value| (precedence, value))
633 }
634 _ => None,
635 }
636}
637
638fn parse_literal_string_expr(
639 tokens: &[Token],
640 aliases: &HashMap<String, String>,
641) -> Option<String> {
642 match tokens {
643 [Token::Str(value)] => Some(truncate_field(value.clone())),
644 [Token::Ident(name)] => aliases.get(name).cloned().map(truncate_field),
645 _ => None,
646 }
647}
648
649fn parse_url_setting(tokens: &[Token], key: &str) -> Option<(u8, String)> {
650 let (precedence, rest) = parse_setting_prefix(tokens);
651 match rest {
652 [Token::Ident(name), Token::Symbol(":="), expr @ ..] if name == key => {
653 parse_url_expr(expr).map(|value| (precedence, value))
654 }
655 _ => None,
656 }
657}
658
659fn parse_url_expr(tokens: &[Token]) -> Option<String> {
660 match tokens {
661 [
662 Token::Ident(some),
663 Token::Symbol("("),
664 Token::Ident(url_fn),
665 Token::Symbol("("),
666 Token::Str(url),
667 Token::Symbol(")"),
668 Token::Symbol(")"),
669 ] if some == "Some" && url_fn == "url" => Some(truncate_field(url.clone())),
670 _ => None,
671 }
672}
673
674fn parse_license_append(tokens: &[Token]) -> Option<LicenseEntry> {
675 match tokens {
676 [
677 Token::Ident(name),
678 Token::Symbol("+="),
679 Token::Str(license_name),
680 Token::Symbol("->"),
681 Token::Ident(url_fn),
682 Token::Symbol("("),
683 Token::Str(url),
684 Token::Symbol(")"),
685 ] if name == "licenses" && url_fn == "url" => Some(LicenseEntry {
686 name: truncate_field(license_name.clone()),
687 url: truncate_field(url.clone()),
688 }),
689 _ => None,
690 }
691}
692
693fn parse_library_dependencies(
694 tokens: &[Token],
695 aliases: &HashMap<String, String>,
696 bundle_aliases: &HashMap<String, Vec<Vec<Token>>>,
697) -> Option<Vec<Dependency>> {
698 let (inherited_scope, tokens) = parse_dependency_setting_prefix(tokens)?;
699
700 match tokens {
701 [Token::Ident(name), Token::Symbol("+="), expr @ ..] if name == "libraryDependencies" => {
702 parse_dependency_expr(expr, aliases, inherited_scope.as_deref())
703 .map(|dependency| vec![dependency])
704 }
705 [Token::Ident(name), Token::Symbol("++="), expr @ ..] if name == "libraryDependencies" => {
706 parse_dependency_seq(expr, aliases, bundle_aliases, inherited_scope.as_deref())
707 }
708 _ => None,
709 }
710}
711
712fn parse_dependency_setting_prefix(tokens: &[Token]) -> Option<(Option<String>, &[Token])> {
713 match tokens {
714 [Token::Ident(scope), Token::Symbol("/"), rest @ ..]
715 if is_supported_config_scope(scope) =>
716 {
717 Some((Some(scope.to_ascii_lowercase()), rest))
718 }
719 _ => Some((None, tokens)),
720 }
721}
722
723fn is_supported_config_scope(scope: &str) -> bool {
724 matches!(
725 scope,
726 "Compile" | "Runtime" | "Provided" | "Test" | "compile" | "runtime" | "provided" | "test"
727 )
728}
729
730fn parse_dependency_seq(
731 tokens: &[Token],
732 aliases: &HashMap<String, String>,
733 bundle_aliases: &HashMap<String, Vec<Vec<Token>>>,
734 inherited_scope: Option<&str>,
735) -> Option<Vec<Dependency>> {
736 let items = if let [Token::Ident(alias_name)] = tokens {
737 bundle_aliases.get(alias_name)?.clone()
738 } else {
739 parse_seq_items(tokens)?
740 };
741
742 let mut dependencies = Vec::new();
743 for item in items.iter().take(MAX_ITERATION_COUNT) {
744 if let Some(dependency) = parse_dependency_expr(item, aliases, inherited_scope) {
745 dependencies.push(dependency);
746 }
747 }
748
749 Some(dependencies)
750}
751
752fn split_by_top_level_commas(tokens: &[Token]) -> Vec<&[Token]> {
753 let mut items = Vec::new();
754 let mut start = 0usize;
755 let mut paren_depth = 0usize;
756 let mut bracket_depth = 0usize;
757 let mut brace_depth = 0usize;
758
759 for (index, token) in tokens.iter().enumerate() {
760 match token {
761 Token::Symbol("(") => paren_depth += 1,
762 Token::Symbol(")") => paren_depth = paren_depth.saturating_sub(1),
763 Token::Symbol("[") => bracket_depth += 1,
764 Token::Symbol("]") => bracket_depth = bracket_depth.saturating_sub(1),
765 Token::Symbol("{") => brace_depth += 1,
766 Token::Symbol("}") => brace_depth = brace_depth.saturating_sub(1),
767 Token::Symbol(",") if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => {
768 if start < index {
769 items.push(&tokens[start..index]);
770 }
771 start = index + 1;
772 }
773 _ => {}
774 }
775 }
776
777 if start < tokens.len() {
778 items.push(&tokens[start..]);
779 }
780
781 items
782}
783
784fn extract_root_settings_items(
785 tokens: &[Token],
786 bundle_aliases: &HashMap<String, Vec<Vec<Token>>>,
787) -> Option<Vec<Vec<Token>>> {
788 let [
789 Token::Ident(lazy),
790 Token::Ident(val_kw),
791 Token::Ident(root),
792 Token::Symbol("="),
793 rest @ ..,
794 ] = tokens
795 else {
796 return None;
797 };
798 if lazy != "lazy" || val_kw != "val" || root != "root" {
799 return None;
800 }
801
802 let inner = if let [
803 Token::Ident(call),
804 Token::Symbol("("),
805 inner @ ..,
806 Token::Symbol(")"),
807 ] = rest
808 {
809 if call != "project.settings" {
810 return None;
811 }
812 inner
813 } else if let Some(settings_index) = rest
814 .iter()
815 .position(|token| matches!(token, Token::Ident(name) if name == "settings"))
816 {
817 if !is_root_project_wrapper(&rest[..settings_index]) {
818 return None;
819 }
820 match &rest[settings_index..] {
821 [
822 Token::Ident(name),
823 Token::Symbol("("),
824 inner @ ..,
825 Token::Symbol(")"),
826 ] if name == "settings" => inner,
827 _ => return None,
828 }
829 } else {
830 return None;
831 };
832
833 let mut expanded = Vec::new();
834 for item in split_by_top_level_commas(inner) {
835 if let [Token::Ident(alias_name)] = item
836 && let Some(bundle_items) = bundle_aliases.get(alias_name)
837 {
838 expanded.extend(bundle_items.clone());
839 } else {
840 expanded.push(item.to_vec());
841 }
842 }
843
844 Some(expanded)
845}
846
847fn is_root_project_wrapper(tokens: &[Token]) -> bool {
848 matches!(
849 tokens,
850 [
851 Token::Symbol("("),
852 Token::Ident(project),
853 Token::Ident(in_kw),
854 Token::Ident(file),
855 Token::Symbol("("),
856 Token::Str(path),
857 Token::Symbol(")"),
858 Token::Symbol(")")
859 ] if project == "project" && in_kw == "in" && file == "file" && path == "."
860 )
861}
862
863fn strip_outer_parens(tokens: &[Token]) -> &[Token] {
864 let mut current = tokens;
865 loop {
866 if current.len() < 2 {
867 return current;
868 }
869
870 if current.first() != Some(&Token::Symbol("("))
871 || current.last() != Some(&Token::Symbol(")"))
872 || !outer_parens_wrap_all(current)
873 {
874 return current;
875 }
876
877 current = ¤t[1..current.len() - 1];
878 }
879}
880
881fn outer_parens_wrap_all(tokens: &[Token]) -> bool {
882 let mut depth = 0usize;
883
884 for (index, token) in tokens.iter().enumerate() {
885 match token {
886 Token::Symbol("(") => depth += 1,
887 Token::Symbol(")") => {
888 depth = depth.saturating_sub(1);
889 if depth == 0 && index + 1 != tokens.len() {
890 return false;
891 }
892 }
893 _ => {}
894 }
895 }
896
897 depth == 0
898}
899
900fn parse_dependency_expr(
901 tokens: &[Token],
902 aliases: &HashMap<String, String>,
903 inherited_scope: Option<&str>,
904) -> Option<Dependency> {
905 let tokens = strip_outer_parens(tokens);
906 if tokens.len() != 5 && tokens.len() != 7 {
907 return None;
908 }
909
910 let operator = match tokens.get(1) {
911 Some(Token::Symbol("%")) => "%",
912 Some(Token::Symbol("%%")) => "%%",
913 _ => return None,
914 };
915
916 if tokens.get(3) != Some(&Token::Symbol("%")) {
917 return None;
918 }
919
920 let group = parse_literal_string_expr(&tokens[0..1], aliases)?;
921 let artifact = parse_literal_string_expr(&tokens[2..3], aliases)?;
922 let version = parse_literal_string_expr(&tokens[4..5], aliases)?;
923 let explicit_scope = if tokens.len() == 7 {
924 if tokens.get(5) != Some(&Token::Symbol("%")) {
925 return None;
926 }
927 Some(parse_scope_expr(tokens.get(6)?)?)
928 } else {
929 None
930 };
931 let scope = explicit_scope.or_else(|| inherited_scope.map(ToOwned::to_owned));
932
933 build_dependency(group, artifact, version, scope, operator)
934}
935
936fn parse_scope_expr(token: &Token) -> Option<String> {
937 match token {
938 Token::Str(value) => Some(value.to_ascii_lowercase()),
939 Token::Ident(value) => Some(value.to_ascii_lowercase()),
940 _ => None,
941 }
942}
943
944fn build_dependency(
945 namespace: String,
946 name: String,
947 version: String,
948 scope: Option<String>,
949 operator: &str,
950) -> Option<Dependency> {
951 let namespace = truncate_field(namespace);
952 let name = truncate_field(name);
953 let version = truncate_field(version);
954 let purl = build_maven_purl(
955 Some(namespace.as_str()),
956 Some(name.as_str()),
957 Some(version.as_str()),
958 )?;
959 let (is_runtime, is_optional) = classify_scope(scope.as_deref());
960 let mut extra_data = HashMap::new();
961
962 if operator == "%%" {
963 extra_data.insert("sbt_cross_version".to_string(), json!(true));
964 extra_data.insert("sbt_operator".to_string(), json!(operator));
965 }
966
967 Some(Dependency {
968 purl: Some(purl),
969 extracted_requirement: Some(version.clone()),
970 scope,
971 is_runtime,
972 is_optional,
973 is_pinned: Some(!version.is_empty()),
974 is_direct: Some(true),
975 resolved_package: None,
976 extra_data: (!extra_data.is_empty()).then_some(extra_data),
977 })
978}
979
980fn classify_scope(scope: Option<&str>) -> (Option<bool>, Option<bool>) {
981 match scope {
982 None => (Some(true), Some(false)),
983 Some("compile") | Some("runtime") => (Some(true), Some(false)),
984 Some("provided") => (Some(false), Some(false)),
985 Some("test") => (Some(false), Some(true)),
986 Some(_) => (None, None),
987 }
988}
989
990fn build_maven_purl(
991 namespace: Option<&str>,
992 name: Option<&str>,
993 version: Option<&str>,
994) -> Option<String> {
995 let name = name?.trim();
996 if name.is_empty() {
997 return None;
998 }
999
1000 let mut purl = PackageUrl::new("maven", name).ok()?;
1001 if let Some(namespace) = namespace.map(str::trim).filter(|value| !value.is_empty()) {
1002 purl.with_namespace(namespace).ok()?;
1003 }
1004 if let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) {
1005 purl.with_version(version).ok()?;
1006 }
1007 Some(purl.to_string())
1008}
1009
1010fn format_license_entries(licenses: &[LicenseEntry]) -> Option<String> {
1011 if licenses.is_empty() {
1012 return None;
1013 }
1014
1015 let mut formatted = String::new();
1016 for license in licenses {
1017 formatted.push_str("- license:\n");
1018 formatted.push_str(" name: ");
1019 formatted.push_str(&license.name);
1020 formatted.push('\n');
1021 formatted.push_str(" url: ");
1022 formatted.push_str(&license.url);
1023 formatted.push('\n');
1024 }
1025
1026 Some(formatted)
1027}