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