Skip to main content

provenant/parsers/
sbt.rs

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