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