Skip to main content

provenant/parsers/
sbt.rs

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