Skip to main content

normalize_manifest/
sbt.rs

1//! Parser for `build.sbt` files (Scala/sbt).
2//!
3//! Extracts `libraryDependencies` declarations:
4//! - `libraryDependencies += "org" %% "name" % "version"`
5//! - `libraryDependencies += "org" % "name" % "version" % Test`
6//! - `libraryDependencies ++= Seq(...)` multi-dep blocks
7
8use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
9
10/// Parser for `build.sbt` files.
11pub struct SbtParser;
12
13impl ManifestParser for SbtParser {
14    fn filename(&self) -> &'static str {
15        "build.sbt"
16    }
17
18    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
19        let mut name = None;
20        let mut version = None;
21        let mut deps = Vec::new();
22
23        // Join continuation lines (lines ending with `\` or inside unclosed parens)
24        // Simple approach: process line by line, accumulating multi-line blocks
25        let mut accumulator = String::new();
26        let mut paren_depth: i32 = 0;
27
28        for line in content.lines() {
29            let trimmed = line.trim();
30
31            if trimmed.is_empty() || trimmed.starts_with("//") {
32                if paren_depth == 0 && !accumulator.trim().is_empty() {
33                    process_sbt_statement(accumulator.trim(), &mut name, &mut version, &mut deps);
34                    accumulator.clear();
35                }
36                continue;
37            }
38
39            accumulator.push(' ');
40            accumulator.push_str(trimmed);
41
42            for ch in trimmed.chars() {
43                match ch {
44                    '(' => paren_depth += 1,
45                    ')' => {
46                        paren_depth -= 1;
47                        if paren_depth < 0 {
48                            paren_depth = 0;
49                        }
50                    }
51                    _ => {}
52                }
53            }
54
55            if paren_depth == 0 {
56                process_sbt_statement(accumulator.trim(), &mut name, &mut version, &mut deps);
57                accumulator.clear();
58            }
59        }
60        if !accumulator.trim().is_empty() {
61            process_sbt_statement(accumulator.trim(), &mut name, &mut version, &mut deps);
62        }
63
64        Ok(ParsedManifest {
65            ecosystem: "sbt",
66            name,
67            version,
68            dependencies: deps,
69        })
70    }
71}
72
73fn process_sbt_statement(
74    stmt: &str,
75    name: &mut Option<String>,
76    version: &mut Option<String>,
77    deps: &mut Vec<DeclaredDep>,
78) {
79    let stmt = stmt.trim();
80
81    // name := "my-project"
82    if stmt.starts_with("name") && stmt.contains(":=") {
83        if let Some(val) = extract_sbt_string(stmt) {
84            *name = Some(val);
85        }
86        return;
87    }
88    // version := "1.0.0"
89    if stmt.starts_with("version") && stmt.contains(":=") {
90        if let Some(val) = extract_sbt_string(stmt) {
91            *version = Some(val);
92        }
93        return;
94    }
95
96    // libraryDependencies += ...  or  libraryDependencies ++= Seq(...)
97    if !stmt.contains("libraryDependencies") {
98        return;
99    }
100
101    // Extract all %% / % separated dep tuples from the statement
102    // Each dep looks like: "org" %% "name" % "version"  optionally % Test/Provided/...
103    parse_sbt_deps(stmt, deps);
104}
105
106fn parse_sbt_deps(stmt: &str, out: &mut Vec<DeclaredDep>) {
107    // Find quoted strings in sequence — groups of 3 are a dep
108    let strings = collect_quoted_strings(stmt);
109
110    // Walk through finding triples separated by % operators
111    // We'll look for sequences: org, artifact, version [, scope]
112    let mut i = 0;
113    while i + 2 < strings.len() {
114        let org = &strings[i];
115        let artifact = &strings[i + 1];
116        let ver = &strings[i + 2];
117
118        // Heuristic: artifact names are lowercase with hyphens/underscores, versions start with digit
119        let name = format!("{}:{}", org, artifact);
120        let version_req = Some(ver.clone());
121
122        // Check for scope keyword after the version string (including its closing quote)
123        let quoted_ver = format!("\"{}\"", ver);
124        let rest_idx = stmt
125            .find(quoted_ver.as_str())
126            .map(|p| p + quoted_ver.len())
127            .unwrap_or(stmt.len());
128        let rest = stmt[rest_idx..].trim();
129        let kind = if rest.starts_with('%') {
130            let scope_part = rest.trim_start_matches('%').trim();
131            if scope_part.starts_with("Test")
132                || scope_part.starts_with('"') && scope_part.contains("test")
133            {
134                DepKind::Dev
135            } else if scope_part.starts_with("Provided") {
136                DepKind::Optional
137            } else {
138                DepKind::Normal
139            }
140        } else {
141            DepKind::Normal
142        };
143
144        out.push(DeclaredDep {
145            name,
146            version_req,
147            kind,
148        });
149
150        i += 3;
151    }
152}
153
154fn collect_quoted_strings(s: &str) -> Vec<String> {
155    let mut result = Vec::new();
156    let mut chars = s.chars().peekable();
157    while let Some(ch) = chars.next() {
158        if ch == '"' {
159            let mut buf = String::new();
160            for inner in chars.by_ref() {
161                if inner == '"' {
162                    break;
163                }
164                buf.push(inner);
165            }
166            result.push(buf);
167        }
168    }
169    result
170}
171
172fn extract_sbt_string(stmt: &str) -> Option<String> {
173    let start = stmt.find('"')? + 1;
174    let end = stmt[start..].find('"')?;
175    Some(stmt[start..start + end].to_string())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::ManifestParser;
182
183    #[test]
184    fn test_parse_build_sbt() {
185        let content = r#"
186name := "my-project"
187version := "0.1.0"
188scalaVersion := "2.13.12"
189
190libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
191libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.5.0"
192libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % Test
193"#;
194        let m = SbtParser.parse(content).unwrap();
195        assert_eq!(m.ecosystem, "sbt");
196        assert_eq!(m.name.as_deref(), Some("my-project"));
197        assert_eq!(m.version.as_deref(), Some("0.1.0"));
198        assert_eq!(m.dependencies.len(), 3);
199
200        let cats = m
201            .dependencies
202            .iter()
203            .find(|d| d.name.contains("cats-core"))
204            .unwrap();
205        assert_eq!(cats.version_req.as_deref(), Some("2.10.0"));
206        assert_eq!(cats.kind, DepKind::Normal);
207
208        let scalatest = m
209            .dependencies
210            .iter()
211            .find(|d| d.name.contains("scalatest"))
212            .unwrap();
213        assert_eq!(scalatest.kind, DepKind::Dev);
214    }
215
216    #[test]
217    fn test_sbt_seq_deps() {
218        let content = r#"
219libraryDependencies ++= Seq(
220  "org.typelevel" %% "cats-core" % "2.10.0",
221  "io.circe" %% "circe-core" % "0.14.6"
222)
223"#;
224        let m = SbtParser.parse(content).unwrap();
225        assert_eq!(m.dependencies.len(), 2);
226    }
227}