1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11pub struct LeinParser;
17
18impl ManifestParser for LeinParser {
19 fn filename(&self) -> &'static str {
20 "project.clj"
21 }
22
23 fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
24 let mut name: Option<String> = None;
25 let mut version: Option<String> = None;
26 let mut deps: Vec<DeclaredDep> = Vec::new();
27
28 if let Some(line) = content
30 .lines()
31 .find(|l| l.trim_start().starts_with("(defproject"))
32 {
33 let header = parse_defproject_header(line);
34 name = header.name;
35 version = header.version;
36 }
37
38 extract_lein_deps(content, &mut deps);
41
42 Ok(ParsedManifest {
43 ecosystem: "clojars",
44 name,
45 version,
46 dependencies: deps,
47 })
48 }
49}
50
51struct ProjectHeader {
52 name: Option<String>,
53 version: Option<String>,
54}
55
56struct DepVectors {
57 deps: Vec<DeclaredDep>,
58 bytes_consumed: usize,
59}
60
61struct BracedContent {
62 content: String,
63 chars_consumed: usize,
64}
65
66fn parse_defproject_header(line: &str) -> ProjectHeader {
68 let after = match line.find("defproject") {
70 Some(i) => &line[i + "defproject".len()..],
71 None => {
72 return ProjectHeader {
73 name: None,
74 version: None,
75 };
76 }
77 };
78
79 let mut tokens = after.split_whitespace();
80 let name = tokens
81 .next()
82 .map(|t| t.trim_matches(['(', ')']).to_string());
83 let version = tokens
84 .next()
85 .map(|t| t.trim_matches(['"', '\'', '(', ')']).to_string());
86
87 ProjectHeader { name, version }
88}
89
90fn extract_lein_deps(content: &str, deps: &mut Vec<DeclaredDep>) {
96 let bytes = content.as_bytes();
99 let len = bytes.len();
100 let mut i = 0;
101
102 while i < len {
103 if let Some(pos) = find_keyword(content, i, ":dependencies") {
105 let after_kw = pos + ":dependencies".len();
106 let bracket_pos = content[after_kw..].find('[').map(|p| after_kw + p);
108
109 if let Some(bp) = bracket_pos {
110 let is_dev = is_inside_dev_profile(content, pos);
112 let kind = if is_dev {
113 DepKind::Dev
114 } else {
115 DepKind::Normal
116 };
117
118 let extracted = extract_dep_vectors(&content[bp..], kind);
120 deps.extend(extracted.deps);
121 i = bp + extracted.bytes_consumed;
122 continue;
123 } else {
124 i = after_kw;
125 continue;
126 }
127 } else {
128 break;
129 }
130 }
131}
132
133fn find_keyword(s: &str, from: usize, keyword: &str) -> Option<usize> {
135 s[from..].find(keyword).map(|p| from + p)
136}
137
138fn is_inside_dev_profile(content: &str, dep_pos: usize) -> bool {
142 let snippet = &content[..dep_pos];
143 if !snippet.contains(":profiles") {
145 return false;
146 }
147 let last_dev = snippet.rfind(":dev");
149 let last_test = snippet.rfind(":test");
150 let last_profile_marker = match (last_dev, last_test) {
151 (Some(a), Some(b)) => Some(a.max(b)),
152 (Some(a), None) => Some(a),
153 (None, Some(b)) => Some(b),
154 (None, None) => None,
155 };
156 last_profile_marker.is_some()
159}
160
161fn extract_dep_vectors(s: &str, kind: DepKind) -> DepVectors {
164 let mut deps = Vec::new();
165 let mut depth = 0i32;
166 let mut i = 0;
167 let chars: Vec<char> = s.chars().collect();
168 let total = chars.len();
169
170 while i < total {
171 match chars[i] {
172 '[' => {
173 depth += 1;
174 if depth == 2 {
175 let mut j = i + 1;
177 let mut inner_depth = 1i32;
178 while j < total {
179 match chars[j] {
180 '[' => inner_depth += 1,
181 ']' => {
182 inner_depth -= 1;
183 if inner_depth == 0 {
184 break;
185 }
186 }
187 _ => {}
188 }
189 j += 1;
190 }
191 let vec_str: String = chars[i..=j].iter().collect();
192 if let Some(dep) = parse_lein_dep_vector(&vec_str, kind) {
193 deps.push(dep);
194 }
195 depth -= 1;
197 i = j + 1;
198 continue;
199 }
200 }
201 ']' => {
202 depth -= 1;
203 if depth == 0 {
204 return DepVectors {
205 deps,
206 bytes_consumed: char_byte_offset(s, i + 1),
207 };
208 }
209 }
210 _ => {}
211 }
212 i += 1;
213 }
214
215 DepVectors {
216 deps,
217 bytes_consumed: s.len(),
218 }
219}
220
221fn char_byte_offset(s: &str, char_idx: usize) -> usize {
222 s.char_indices()
223 .nth(char_idx)
224 .map(|(b, _)| b)
225 .unwrap_or(s.len())
226}
227
228fn parse_lein_dep_vector(s: &str, kind: DepKind) -> Option<DeclaredDep> {
230 let inner = s
232 .trim()
233 .trim_start_matches('[')
234 .trim_end_matches(']')
235 .trim();
236 if inner.is_empty() {
237 return None;
238 }
239
240 let mut tokens = inner.split_whitespace();
242 let name_token = tokens.next()?.trim_matches(['"', '\'']);
243 if name_token.is_empty() {
244 return None;
245 }
246
247 let version_req = tokens
249 .next()
250 .map(|t| t.trim_matches(['"', '\'', ',']))
251 .filter(|t| !t.is_empty())
252 .map(|t| t.to_string());
253
254 Some(DeclaredDep {
255 name: name_token.to_string(),
256 version_req,
257 kind,
258 })
259}
260
261pub struct EclojureParser;
267
268impl ManifestParser for EclojureParser {
269 fn filename(&self) -> &'static str {
270 "deps.edn"
271 }
272
273 fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
274 let mut deps: Vec<DeclaredDep> = Vec::new();
275
276 extract_edn_deps(content, DepKind::Normal, &mut deps);
278
279 extract_edn_alias_deps(content, &mut deps);
281
282 Ok(ParsedManifest {
283 ecosystem: "clojars",
284 name: None,
285 version: None,
286 dependencies: deps,
287 })
288 }
289}
290
291fn extract_edn_deps(content: &str, kind: DepKind, deps: &mut Vec<DeclaredDep>) {
293 let Some(kw_pos) = content.find(":deps") else {
294 return;
295 };
296 let after = &content[kw_pos + ":deps".len()..];
297 let Some(brace_pos) = after.find('{') else {
298 return;
299 };
300 let map_str = &after[brace_pos..];
301 parse_edn_dep_map(map_str, kind, deps);
302}
303
304fn extract_edn_alias_deps(content: &str, deps: &mut Vec<DeclaredDep>) {
306 let Some(aliases_pos) = content.find(":aliases") else {
307 return;
308 };
309 let after_aliases = &content[aliases_pos + ":aliases".len()..];
310 let Some(outer_brace) = after_aliases.find('{') else {
311 return;
312 };
313 let aliases_map = &after_aliases[outer_brace..];
315
316 for marker in &[":dev", ":test"] {
318 let mut search_start = 0;
319 while let Some(rel) = aliases_map[search_start..].find(marker) {
320 let abs = search_start + rel;
321 let before = &aliases_map[..abs];
323 let is_keyword = before
324 .chars()
325 .last()
326 .map(|c| c.is_whitespace() || c == '{')
327 .unwrap_or(true);
328 if !is_keyword {
329 search_start = abs + marker.len();
330 continue;
331 }
332
333 let after_marker = &aliases_map[abs + marker.len()..];
334 if let Some(ed_pos) = after_marker.find(":extra-deps") {
336 let after_ed = &after_marker[ed_pos + ":extra-deps".len()..];
337 if let Some(b) = after_ed.find('{') {
338 let map_str = &after_ed[b..];
339 parse_edn_dep_map(map_str, DepKind::Dev, deps);
340 }
341 }
342 search_start = abs + marker.len();
343 }
344 }
345}
346
347fn parse_edn_dep_map(s: &str, kind: DepKind, deps: &mut Vec<DeclaredDep>) {
349 let chars: Vec<char> = s.chars().collect();
353 let total = chars.len();
354 let mut i = 0;
355
356 if chars.is_empty() || chars[0] != '{' {
358 return;
359 }
360 i += 1; while i < total {
363 while i < total && (chars[i].is_whitespace() || chars[i] == ',') {
365 i += 1;
366 }
367 if i >= total || chars[i] == '}' {
368 break;
369 }
370
371 if chars[i] == ';' {
373 while i < total && chars[i] != '\n' {
375 i += 1;
376 }
377 continue;
378 }
379
380 let name_start = i;
381 while i < total
382 && !chars[i].is_whitespace()
383 && chars[i] != '{'
384 && chars[i] != '}'
385 && chars[i] != ','
386 {
387 i += 1;
388 }
389 let dep_name: String = chars[name_start..i].iter().collect();
390 let dep_name = dep_name.trim_matches(['"', '\'', ':']);
391
392 if dep_name.is_empty() {
393 i += 1;
394 continue;
395 }
396
397 while i < total && chars[i].is_whitespace() {
399 i += 1;
400 }
401
402 if i >= total {
403 break;
404 }
405
406 if chars[i] == '{' {
408 let braced = extract_braced(&chars[i..]);
409 let version_req = extract_mvn_version(&braced.content);
410 deps.push(DeclaredDep {
411 name: dep_name.to_string(),
412 version_req,
413 kind,
414 });
415 i += braced.chars_consumed;
416 } else {
417 i += 1;
419 }
420 }
421}
422
423fn extract_braced(chars: &[char]) -> BracedContent {
425 let mut depth = 0i32;
426 let mut result = String::new();
427 for (idx, &ch) in chars.iter().enumerate() {
428 match ch {
429 '{' => depth += 1,
430 '}' => {
431 depth -= 1;
432 if depth == 0 {
433 result.push(ch);
434 return BracedContent {
435 content: result,
436 chars_consumed: idx + 1,
437 };
438 }
439 }
440 _ => {}
441 }
442 result.push(ch);
443 }
444 BracedContent {
445 content: result,
446 chars_consumed: chars.len(),
447 }
448}
449
450fn extract_mvn_version(s: &str) -> Option<String> {
452 let kw = ":mvn/version";
453 let pos = s.find(kw)?;
454 let after = s[pos + kw.len()..].trim_start();
455 let quote_start = after.find('"')?;
457 let inner = &after[quote_start + 1..];
458 let quote_end = inner.find('"')?;
459 Some(inner[..quote_end].to_string())
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::ManifestParser;
466
467 #[test]
468 fn test_parse_project_clj() {
469 let content = r#"(defproject myapp "0.1.0-SNAPSHOT"
470 :description "My application"
471 :url "http://example.com"
472 :dependencies [[org.clojure/clojure "1.11.1"]
473 [ring/ring-core "1.9.6"]
474 [compojure "1.7.0"]]
475 :profiles {:dev {:dependencies [[midje "1.10.9"]
476 [ring/ring-mock "0.4.0"]]}})
477"#;
478 let m = LeinParser.parse(content).unwrap();
479 assert_eq!(m.ecosystem, "clojars");
480 assert_eq!(m.name.as_deref(), Some("myapp"));
481 assert_eq!(m.version.as_deref(), Some("0.1.0-SNAPSHOT"));
482
483 let clojure = m
484 .dependencies
485 .iter()
486 .find(|d| d.name == "org.clojure/clojure")
487 .unwrap();
488 assert_eq!(clojure.version_req.as_deref(), Some("1.11.1"));
489 assert_eq!(clojure.kind, DepKind::Normal);
490
491 let ring = m
492 .dependencies
493 .iter()
494 .find(|d| d.name == "ring/ring-core")
495 .unwrap();
496 assert_eq!(ring.version_req.as_deref(), Some("1.9.6"));
497
498 let midje = m.dependencies.iter().find(|d| d.name == "midje").unwrap();
499 assert_eq!(midje.kind, DepKind::Dev);
500 assert_eq!(midje.version_req.as_deref(), Some("1.10.9"));
501
502 let mock = m
503 .dependencies
504 .iter()
505 .find(|d| d.name == "ring/ring-mock")
506 .unwrap();
507 assert_eq!(mock.kind, DepKind::Dev);
508 }
509
510 #[test]
511 fn test_parse_deps_edn() {
512 let content = r#"{:deps {org.clojure/clojure {:mvn/version "1.11.1"}
513 ring/ring-core {:mvn/version "1.9.6"}
514 io.github.user/mylib {:git/url "https://github.com/user/mylib"
515 :git/sha "abc123def456"}}
516 :aliases {:dev {:extra-deps {cider/cider-nrepl {:mvn/version "0.45.0"}
517 nrepl/nrepl {:mvn/version "1.0.0"}}}
518 :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1342"}}}}}
519"#;
520 let m = EclojureParser.parse(content).unwrap();
521 assert_eq!(m.ecosystem, "clojars");
522
523 let clojure = m
524 .dependencies
525 .iter()
526 .find(|d| d.name == "org.clojure/clojure")
527 .unwrap();
528 assert_eq!(clojure.version_req.as_deref(), Some("1.11.1"));
529 assert_eq!(clojure.kind, DepKind::Normal);
530
531 let mylib = m
532 .dependencies
533 .iter()
534 .find(|d| d.name == "io.github.user/mylib")
535 .unwrap();
536 assert!(mylib.version_req.is_none());
538
539 let cider = m
540 .dependencies
541 .iter()
542 .find(|d| d.name == "cider/cider-nrepl")
543 .unwrap();
544 assert_eq!(cider.kind, DepKind::Dev);
545 assert_eq!(cider.version_req.as_deref(), Some("0.45.0"));
546
547 let kaocha = m
548 .dependencies
549 .iter()
550 .find(|d| d.name == "lambdaisland/kaocha")
551 .unwrap();
552 assert_eq!(kaocha.kind, DepKind::Dev);
553 }
554}