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