Skip to main content

provenant/parsers/
sbt.rs

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 = &current[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);