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