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