Skip to main content

normalize_manifest/
common_lisp.rs

1//! Parser for `*.asd` files (Common Lisp/ASDF).
2//!
3//! ASDF system definition files use Common Lisp syntax. We heuristically
4//! extract `(asdf:defsystem ...)` blocks and their `:depends-on (...)` lists
5//! without executing Lisp, using the shared [`crate::sexpr`] parser.
6
7use crate::sexpr::Sexp;
8use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
9
10/// Parser for `*.asd` files.
11///
12/// Since ASDF files use non-standard filenames (e.g. `my-system.asd`), this
13/// parser is not registered in `parse_manifest()` by filename. Use
14/// `parse_manifest_by_extension("asd", content)` or call `AsdParser` directly.
15pub struct AsdParser;
16
17impl ManifestParser for AsdParser {
18    fn filename(&self) -> &'static str {
19        "*.asd"
20    }
21
22    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
23        let mut name: Option<String> = None;
24        let mut version: Option<String> = None;
25        let mut deps: Vec<DeclaredDep> = Vec::new();
26
27        for token in &Sexp::parse(content) {
28            let Some(children) = token.as_list() else {
29                continue;
30            };
31            let head = match children.first() {
32                Some(Sexp::Atom(s)) => s.as_str(),
33                _ => continue,
34            };
35            if head == "asdf:defsystem" || head == "defsystem" || head == "asdf/defsystem:defsystem"
36            {
37                parse_defsystem(children, &mut name, &mut version, &mut deps);
38            }
39        }
40
41        Ok(ParsedManifest {
42            ecosystem: "quicklisp",
43            name,
44            version,
45            dependencies: deps,
46        })
47    }
48}
49
50fn parse_defsystem(
51    children: &[Sexp],
52    name: &mut Option<String>,
53    version: &mut Option<String>,
54    deps: &mut Vec<DeclaredDep>,
55) {
56    // children[0] = "defsystem", children[1] = system-name, rest = keyword/value pairs.
57    if name.is_none()
58        && let Some(sys_name) = children.get(1).and_then(|t| t.as_text())
59    {
60        *name = Some(strip_hash_colon(sys_name));
61    }
62
63    let mut i = 2;
64    while i < children.len() {
65        if let Sexp::Atom(kw) = &children[i] {
66            match kw.to_lowercase().as_str() {
67                ":version" => {
68                    if version.is_none()
69                        && let Some(v) = children.get(i + 1).and_then(|t| t.as_text())
70                    {
71                        *version = Some(v.to_string());
72                    }
73                    i += 2;
74                    continue;
75                }
76                ":depends-on" => {
77                    if let Some(dep_list) = children.get(i + 1).and_then(|t| t.as_list()) {
78                        parse_depends_on(dep_list, deps);
79                    }
80                    i += 2;
81                    continue;
82                }
83                _ => {}
84            }
85        }
86        i += 1;
87    }
88}
89
90fn parse_depends_on(dep_list: &[Sexp], deps: &mut Vec<DeclaredDep>) {
91    for token in dep_list {
92        match token {
93            Sexp::Atom(s) => {
94                let dep_name = strip_hash_colon(s);
95                if !dep_name.is_empty() {
96                    deps.push(DeclaredDep {
97                        name: dep_name,
98                        version_req: None,
99                        kind: DepKind::Normal,
100                    });
101                }
102            }
103            Sexp::Str(s) => {
104                if !s.is_empty() {
105                    deps.push(DeclaredDep {
106                        name: s.clone(),
107                        version_req: None,
108                        kind: DepKind::Normal,
109                    });
110                }
111            }
112            Sexp::List(sub) => {
113                if let Some(dep) = parse_dep_form(sub) {
114                    deps.push(dep);
115                }
116            }
117        }
118    }
119}
120
121/// Parse compound dependency forms:
122/// - `(:version #:name "ver")` → Normal with version_req
123/// - `(:feature :platform #:name)` → Optional
124/// - `(:require ...)` → skip
125fn parse_dep_form(sub: &[Sexp]) -> Option<DeclaredDep> {
126    let head = match sub.first() {
127        Some(Sexp::Atom(s)) => s.to_lowercase(),
128        _ => return None,
129    };
130
131    match head.as_str() {
132        ":version" => {
133            // (:version #:name "ver")
134            let dep_name = sub.get(1)?.as_text().map(strip_hash_colon)?;
135            if dep_name.is_empty() {
136                return None;
137            }
138            let version_req = sub.get(2).and_then(|t| t.as_text()).map(|s| s.to_string());
139            Some(DeclaredDep {
140                name: dep_name,
141                version_req,
142                kind: DepKind::Normal,
143            })
144        }
145        ":feature" => {
146            // (:feature :keyword #:dep-name)
147            // The dep name is the last non-keyword symbol in the form.
148            let dep_name = sub.iter().rev().find_map(|t| {
149                if let Sexp::Atom(s) = t {
150                    let stripped = strip_hash_colon(s);
151                    if !stripped.is_empty() && !stripped.starts_with(':') {
152                        return Some(stripped);
153                    }
154                }
155                None
156            })?;
157            Some(DeclaredDep {
158                name: dep_name,
159                version_req: None,
160                kind: DepKind::Optional,
161            })
162        }
163        _ => None,
164    }
165}
166
167fn strip_hash_colon(s: &str) -> String {
168    s.trim_start_matches('#')
169        .trim_start_matches(':')
170        .to_string()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::ManifestParser;
177
178    const SAMPLE: &str = r#"(asdf:defsystem #:my-system
179  :name "my-system"
180  :version "1.0.0"
181  :description "My CL system"
182  :depends-on (#:alexandria
183               #:cl-ppcre
184               (:version #:bordeaux-threads "0.8.0")
185               (:feature :sbcl #:sb-posix))
186  :in-order-to ((test-op (test-op #:my-system/tests))))
187
188(asdf:defsystem #:my-system/tests
189  :depends-on (#:my-system #:fiveam))
190"#;
191
192    #[test]
193    fn test_parse_asd() {
194        let m = AsdParser.parse(SAMPLE).unwrap();
195        assert_eq!(m.ecosystem, "quicklisp");
196        assert_eq!(m.name.as_deref(), Some("my-system"));
197        assert_eq!(m.version.as_deref(), Some("1.0.0"));
198
199        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
200        assert!(names.contains(&"alexandria"), "{names:?}");
201        assert!(names.contains(&"cl-ppcre"), "{names:?}");
202        assert!(names.contains(&"bordeaux-threads"), "{names:?}");
203        assert!(names.contains(&"sb-posix"), "{names:?}");
204
205        let bt = m
206            .dependencies
207            .iter()
208            .find(|d| d.name == "bordeaux-threads")
209            .unwrap();
210        assert_eq!(bt.version_req.as_deref(), Some("0.8.0"));
211        assert_eq!(bt.kind, DepKind::Normal);
212
213        let sbposix = m
214            .dependencies
215            .iter()
216            .find(|d| d.name == "sb-posix")
217            .unwrap();
218        assert_eq!(sbposix.kind, DepKind::Optional);
219    }
220
221    #[test]
222    fn test_multiple_systems_deps_merged() {
223        // Only the first system's name/version are used; both systems' deps are
224        // collected when iterating over top-level forms.
225        let m = AsdParser.parse(SAMPLE).unwrap();
226        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
227        // my-system/tests also depends on my-system and fiveam.
228        assert!(names.contains(&"fiveam"), "{names:?}");
229    }
230}