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