use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
pub struct LeinParser;
impl ManifestParser for LeinParser {
fn filename(&self) -> &'static str {
"project.clj"
}
fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
let mut name: Option<String> = None;
let mut version: Option<String> = None;
let mut deps: Vec<DeclaredDep> = Vec::new();
if let Some(line) = content
.lines()
.find(|l| l.trim_start().starts_with("(defproject"))
{
let header = parse_defproject_header(line);
name = header.name;
version = header.version;
}
extract_lein_deps(content, &mut deps);
Ok(ParsedManifest {
ecosystem: "clojars",
name,
version,
dependencies: deps,
})
}
}
struct ProjectHeader {
name: Option<String>,
version: Option<String>,
}
struct DepVectors {
deps: Vec<DeclaredDep>,
bytes_consumed: usize,
}
struct BracedContent {
content: String,
chars_consumed: usize,
}
fn parse_defproject_header(line: &str) -> ProjectHeader {
let after = match line.find("defproject") {
Some(i) => &line[i + "defproject".len()..],
None => {
return ProjectHeader {
name: None,
version: None,
};
}
};
let mut tokens = after.split_whitespace();
let name = tokens
.next()
.map(|t| t.trim_matches(['(', ')']).to_string());
let version = tokens
.next()
.map(|t| t.trim_matches(['"', '\'', '(', ')']).to_string());
ProjectHeader { name, version }
}
fn extract_lein_deps(content: &str, deps: &mut Vec<DeclaredDep>) {
let bytes = content.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if let Some(pos) = find_keyword(content, i, ":dependencies") {
let after_kw = pos + ":dependencies".len();
let bracket_pos = content[after_kw..].find('[').map(|p| after_kw + p);
if let Some(bp) = bracket_pos {
let is_dev = is_inside_dev_profile(content, pos);
let kind = if is_dev {
DepKind::Dev
} else {
DepKind::Normal
};
let extracted = extract_dep_vectors(&content[bp..], kind);
deps.extend(extracted.deps);
i = bp + extracted.bytes_consumed;
continue;
} else {
i = after_kw;
continue;
}
} else {
break;
}
}
}
fn find_keyword(s: &str, from: usize, keyword: &str) -> Option<usize> {
s[from..].find(keyword).map(|p| from + p)
}
fn is_inside_dev_profile(content: &str, dep_pos: usize) -> bool {
let snippet = &content[..dep_pos];
if !snippet.contains(":profiles") {
return false;
}
let last_dev = snippet.rfind(":dev");
let last_test = snippet.rfind(":test");
let last_profile_marker = match (last_dev, last_test) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
last_profile_marker.is_some()
}
fn extract_dep_vectors(s: &str, kind: DepKind) -> DepVectors {
let mut deps = Vec::new();
let mut depth = 0i32;
let mut i = 0;
let chars: Vec<char> = s.chars().collect();
let total = chars.len();
while i < total {
match chars[i] {
'[' => {
depth += 1;
if depth == 2 {
let mut j = i + 1;
let mut inner_depth = 1i32;
while j < total {
match chars[j] {
'[' => inner_depth += 1,
']' => {
inner_depth -= 1;
if inner_depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
let vec_str: String = chars[i..=j].iter().collect();
if let Some(dep) = parse_lein_dep_vector(&vec_str, kind) {
deps.push(dep);
}
depth -= 1;
i = j + 1;
continue;
}
}
']' => {
depth -= 1;
if depth == 0 {
return DepVectors {
deps,
bytes_consumed: char_byte_offset(s, i + 1),
};
}
}
_ => {}
}
i += 1;
}
DepVectors {
deps,
bytes_consumed: s.len(),
}
}
fn char_byte_offset(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn parse_lein_dep_vector(s: &str, kind: DepKind) -> Option<DeclaredDep> {
let inner = s
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.trim();
if inner.is_empty() {
return None;
}
let mut tokens = inner.split_whitespace();
let name_token = tokens.next()?.trim_matches(['"', '\'']);
if name_token.is_empty() {
return None;
}
let version_req = tokens
.next()
.map(|t| t.trim_matches(['"', '\'', ',']))
.filter(|t| !t.is_empty())
.map(|t| t.to_string());
Some(DeclaredDep {
name: name_token.to_string(),
version_req,
kind,
})
}
pub struct EclojureParser;
impl ManifestParser for EclojureParser {
fn filename(&self) -> &'static str {
"deps.edn"
}
fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
let mut deps: Vec<DeclaredDep> = Vec::new();
extract_edn_deps(content, DepKind::Normal, &mut deps);
extract_edn_alias_deps(content, &mut deps);
Ok(ParsedManifest {
ecosystem: "clojars",
name: None,
version: None,
dependencies: deps,
})
}
}
fn extract_edn_deps(content: &str, kind: DepKind, deps: &mut Vec<DeclaredDep>) {
let Some(kw_pos) = content.find(":deps") else {
return;
};
let after = &content[kw_pos + ":deps".len()..];
let Some(brace_pos) = after.find('{') else {
return;
};
let map_str = &after[brace_pos..];
parse_edn_dep_map(map_str, kind, deps);
}
fn extract_edn_alias_deps(content: &str, deps: &mut Vec<DeclaredDep>) {
let Some(aliases_pos) = content.find(":aliases") else {
return;
};
let after_aliases = &content[aliases_pos + ":aliases".len()..];
let Some(outer_brace) = after_aliases.find('{') else {
return;
};
let aliases_map = &after_aliases[outer_brace..];
for marker in &[":dev", ":test"] {
let mut search_start = 0;
while let Some(rel) = aliases_map[search_start..].find(marker) {
let abs = search_start + rel;
let before = &aliases_map[..abs];
let is_keyword = before
.chars()
.last()
.map(|c| c.is_whitespace() || c == '{')
.unwrap_or(true);
if !is_keyword {
search_start = abs + marker.len();
continue;
}
let after_marker = &aliases_map[abs + marker.len()..];
if let Some(ed_pos) = after_marker.find(":extra-deps") {
let after_ed = &after_marker[ed_pos + ":extra-deps".len()..];
if let Some(b) = after_ed.find('{') {
let map_str = &after_ed[b..];
parse_edn_dep_map(map_str, DepKind::Dev, deps);
}
}
search_start = abs + marker.len();
}
}
}
fn parse_edn_dep_map(s: &str, kind: DepKind, deps: &mut Vec<DeclaredDep>) {
let chars: Vec<char> = s.chars().collect();
let total = chars.len();
let mut i = 0;
if chars.is_empty() || chars[0] != '{' {
return;
}
i += 1;
while i < total {
while i < total && (chars[i].is_whitespace() || chars[i] == ',') {
i += 1;
}
if i >= total || chars[i] == '}' {
break;
}
if chars[i] == ';' {
while i < total && chars[i] != '\n' {
i += 1;
}
continue;
}
let name_start = i;
while i < total
&& !chars[i].is_whitespace()
&& chars[i] != '{'
&& chars[i] != '}'
&& chars[i] != ','
{
i += 1;
}
let dep_name: String = chars[name_start..i].iter().collect();
let dep_name = dep_name.trim_matches(['"', '\'', ':']);
if dep_name.is_empty() {
i += 1;
continue;
}
while i < total && chars[i].is_whitespace() {
i += 1;
}
if i >= total {
break;
}
if chars[i] == '{' {
let braced = extract_braced(&chars[i..]);
let version_req = extract_mvn_version(&braced.content);
deps.push(DeclaredDep {
name: dep_name.to_string(),
version_req,
kind,
});
i += braced.chars_consumed;
} else {
i += 1;
}
}
}
fn extract_braced(chars: &[char]) -> BracedContent {
let mut depth = 0i32;
let mut result = String::new();
for (idx, &ch) in chars.iter().enumerate() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
result.push(ch);
return BracedContent {
content: result,
chars_consumed: idx + 1,
};
}
}
_ => {}
}
result.push(ch);
}
BracedContent {
content: result,
chars_consumed: chars.len(),
}
}
fn extract_mvn_version(s: &str) -> Option<String> {
let kw = ":mvn/version";
let pos = s.find(kw)?;
let after = s[pos + kw.len()..].trim_start();
let quote_start = after.find('"')?;
let inner = &after[quote_start + 1..];
let quote_end = inner.find('"')?;
Some(inner[..quote_end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ManifestParser;
#[test]
fn test_parse_project_clj() {
let content = r#"(defproject myapp "0.1.0-SNAPSHOT"
:description "My application"
:url "http://example.com"
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.9.6"]
[compojure "1.7.0"]]
:profiles {:dev {:dependencies [[midje "1.10.9"]
[ring/ring-mock "0.4.0"]]}})
"#;
let m = LeinParser.parse(content).unwrap();
assert_eq!(m.ecosystem, "clojars");
assert_eq!(m.name.as_deref(), Some("myapp"));
assert_eq!(m.version.as_deref(), Some("0.1.0-SNAPSHOT"));
let clojure = m
.dependencies
.iter()
.find(|d| d.name == "org.clojure/clojure")
.unwrap();
assert_eq!(clojure.version_req.as_deref(), Some("1.11.1"));
assert_eq!(clojure.kind, DepKind::Normal);
let ring = m
.dependencies
.iter()
.find(|d| d.name == "ring/ring-core")
.unwrap();
assert_eq!(ring.version_req.as_deref(), Some("1.9.6"));
let midje = m.dependencies.iter().find(|d| d.name == "midje").unwrap();
assert_eq!(midje.kind, DepKind::Dev);
assert_eq!(midje.version_req.as_deref(), Some("1.10.9"));
let mock = m
.dependencies
.iter()
.find(|d| d.name == "ring/ring-mock")
.unwrap();
assert_eq!(mock.kind, DepKind::Dev);
}
#[test]
fn test_parse_deps_edn() {
let content = r#"{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
ring/ring-core {:mvn/version "1.9.6"}
io.github.user/mylib {:git/url "https://github.com/user/mylib"
:git/sha "abc123def456"}}
:aliases {:dev {:extra-deps {cider/cider-nrepl {:mvn/version "0.45.0"}
nrepl/nrepl {:mvn/version "1.0.0"}}}
:test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1342"}}}}}
"#;
let m = EclojureParser.parse(content).unwrap();
assert_eq!(m.ecosystem, "clojars");
let clojure = m
.dependencies
.iter()
.find(|d| d.name == "org.clojure/clojure")
.unwrap();
assert_eq!(clojure.version_req.as_deref(), Some("1.11.1"));
assert_eq!(clojure.kind, DepKind::Normal);
let mylib = m
.dependencies
.iter()
.find(|d| d.name == "io.github.user/mylib")
.unwrap();
assert!(mylib.version_req.is_none());
let cider = m
.dependencies
.iter()
.find(|d| d.name == "cider/cider-nrepl")
.unwrap();
assert_eq!(cider.kind, DepKind::Dev);
assert_eq!(cider.version_req.as_deref(), Some("0.45.0"));
let kaocha = m
.dependencies
.iter()
.find(|d| d.name == "lambdaisland/kaocha")
.unwrap();
assert_eq!(kaocha.kind, DepKind::Dev);
}
}