Skip to main content

provenant/parsers/
sbt.rs

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