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