Skip to main content

normalize_manifest/
clojure.rs

1//! Parsers for Clojure manifest files.
2//!
3//! - `LeinParser`: `project.clj` (Leiningen) — extracts `defproject` name/version
4//!   and `[group/artifact "version"]` deps from `:dependencies`. Dev deps from
5//!   `:profiles {:dev {:dependencies [...]}}`.
6//! - `EclojureParser`: `deps.edn` (Clojure CLI) — extracts `{dep/name {:mvn/version "x"}}`
7//!   pairs from `:deps`. Alias deps (`:dev`, `:test`) → `DepKind::Dev`.
8
9use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11// ============================================================================
12// Leiningen — project.clj
13// ============================================================================
14
15/// Parser for `project.clj` files (Leiningen/Clojars).
16pub 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        // Extract name and version from: (defproject myapp "0.1.0-SNAPSHOT"
29        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 all dep vectors from the whole content.
39        // We parse the full content tracking whether we're in a :dev profile.
40        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
66/// Parse `(defproject myapp "0.1.0-SNAPSHOT" ...` → name + version.
67fn parse_defproject_header(line: &str) -> ProjectHeader {
68    // Tokens after `defproject`
69    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
90/// Extract Leiningen dependency vectors from content.
91///
92/// Heuristically scans for `[group/artifact "version"]` patterns.
93/// We track `:profiles {:dev ...}` by scanning for the `:profiles` keyword
94/// and then checking if we're inside a `:dev` or `:test` block.
95fn extract_lein_deps(content: &str, deps: &mut Vec<DeclaredDep>) {
96    // State machine: find `:dependencies [` blocks and their context.
97    // We do a text scan rather than full EDN parsing.
98    let bytes = content.as_bytes();
99    let len = bytes.len();
100    let mut i = 0;
101
102    while i < len {
103        // Look for `:dependencies`
104        if let Some(pos) = find_keyword(content, i, ":dependencies") {
105            let after_kw = pos + ":dependencies".len();
106            // Skip whitespace, then expect `[`
107            let bracket_pos = content[after_kw..].find('[').map(|p| after_kw + p);
108
109            if let Some(bp) = bracket_pos {
110                // Determine if this `:dependencies` is inside a dev/test profile
111                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                // Extract all `[name "version"]` entries inside this bracket
119                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
133/// Find `keyword` starting at or after `from` in `s`. Returns byte position.
134fn find_keyword(s: &str, from: usize, keyword: &str) -> Option<usize> {
135    s[from..].find(keyword).map(|p| from + p)
136}
137
138/// Returns true if the position `pos` in `content` appears to be inside a
139/// `:dev` or `:test` profile block. Heuristic: look backwards for `:dev` or
140/// `:test` before the `:dependencies` token, within a `:profiles` context.
141fn is_inside_dev_profile(content: &str, dep_pos: usize) -> bool {
142    let snippet = &content[..dep_pos];
143    // Must have :profiles somewhere before this point
144    if !snippet.contains(":profiles") {
145        return false;
146    }
147    // Find the last `:dev` or `:test` before this position
148    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    // Also find the last top-level :dependencies (position 0 context)
157    // If the last profile marker is closer to dep_pos than the :profiles keyword, we're in it.
158    last_profile_marker.is_some()
159}
160
161/// Extract `[name "version"]` dep vectors starting from an outer `[`.
162/// Returns deps and bytes consumed from start of outer bracket.
163fn 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                    // Start of a dep vector — collect until matching `]`
176                    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                    // We consumed the `[...]` pair, so balance depth back to 1
196                    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
228/// Parse `[group/artifact "1.0.0"]` → DeclaredDep.
229fn parse_lein_dep_vector(s: &str, kind: DepKind) -> Option<DeclaredDep> {
230    // Strip outer brackets
231    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    // First token is the artifact name
241    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    // Second token (if present) is the version string
248    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
261// ============================================================================
262// Clojure CLI — deps.edn
263// ============================================================================
264
265/// Parser for `deps.edn` files (Clojure CLI / clojars).
266pub 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 top-level :deps block
277        extract_edn_deps(content, DepKind::Normal, &mut deps);
278
279        // Extract :aliases blocks for :dev and :test
280        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
291/// Extract `{dep/name {:mvn/version "x"}}` pairs from a `:deps` map in `content`.
292fn 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
304/// Scan `:aliases` block and extract `:dev`/`:test` extra-deps as Dev.
305fn 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    // Extract the outer aliases map
314    let aliases_map = &after_aliases[outer_brace..];
315
316    // Find each :dev and :test block inside
317    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            // Make sure it's a keyword (preceded by whitespace or `{`)
322            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            // Look for :extra-deps inside this alias block
335            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
347/// Parse an EDN map of the form `{dep/name {:mvn/version "x"} ...}` into deps.
348fn parse_edn_dep_map(s: &str, kind: DepKind, deps: &mut Vec<DeclaredDep>) {
349    // We scan character by character tracking brace depth.
350    // At depth 1 (inside the outer map), we collect symbol tokens as dep names
351    // and then look for {:mvn/version "x"} values.
352    let chars: Vec<char> = s.chars().collect();
353    let total = chars.len();
354    let mut i = 0;
355
356    // Skip to opening brace
357    if chars.is_empty() || chars[0] != '{' {
358        return;
359    }
360    i += 1; // past '{'
361
362    while i < total {
363        // Skip whitespace and commas
364        while i < total && (chars[i].is_whitespace() || chars[i] == ',') {
365            i += 1;
366        }
367        if i >= total || chars[i] == '}' {
368            break;
369        }
370
371        // Read the dep name symbol (e.g., `org.clojure/clojure`)
372        if chars[i] == ';' {
373            // EDN line comment — skip to end of line
374            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        // Skip whitespace
398        while i < total && chars[i].is_whitespace() {
399            i += 1;
400        }
401
402        if i >= total {
403            break;
404        }
405
406        // Read the value — should be a map `{...}`
407        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            // Unexpected token — skip
418            i += 1;
419        }
420    }
421}
422
423/// Extract a `{...}` block from a char slice (including nested braces).
424fn 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
450/// Extract `:mvn/version "x"` from a string like `{:mvn/version "1.11.1"}`.
451fn 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    // Next quoted string
456    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        // git dep has no :mvn/version
537        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}