Skip to main content

normalize_manifest/
racket.rs

1//! Parser for `info.rkt` files (Racket).
2//!
3//! Racket package info files use `#lang info` followed by `define` expressions.
4//! We heuristically extract `collection`/`name`, `version`, `deps`, and
5//! `build-deps` without executing Racket, using the shared [`crate::sexpr`] parser.
6
7use crate::sexpr::{Sexp, kw_arg};
8use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
9
10/// Parser for `info.rkt` files.
11pub struct RacketInfoParser;
12
13impl ManifestParser for RacketInfoParser {
14    fn filename(&self) -> &'static str {
15        "info.rkt"
16    }
17
18    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
19        let mut name: Option<String> = None;
20        let mut version: Option<String> = None;
21        let mut deps: Vec<DeclaredDep> = Vec::new();
22
23        for token in &Sexp::parse(content) {
24            if let Some(items) = token.tagged_list("define") {
25                parse_define(items, &mut name, &mut version, &mut deps);
26            }
27        }
28
29        Ok(ParsedManifest {
30            ecosystem: "racket",
31            name,
32            version,
33            dependencies: deps,
34        })
35    }
36}
37
38fn parse_define(
39    items: &[Sexp],
40    name: &mut Option<String>,
41    version: &mut Option<String>,
42    deps: &mut Vec<DeclaredDep>,
43) {
44    let key = match items.first() {
45        Some(Sexp::Atom(k)) => k.as_str(),
46        _ => return,
47    };
48
49    match key {
50        "collection" | "name" => {
51            if name.is_none()
52                && let Some(val) = first_text_value(&items[1..])
53            {
54                *name = Some(val);
55            }
56        }
57        "version" => {
58            if version.is_none()
59                && let Some(val) = first_text_value(&items[1..])
60            {
61                *version = Some(val);
62            }
63        }
64        "deps" => {
65            collect_dep_list(&items[1..], DepKind::Normal, deps);
66        }
67        "build-deps" => {
68            collect_dep_list(&items[1..], DepKind::Dev, deps);
69        }
70        _ => {}
71    }
72}
73
74/// Return the first string or atom value from a token slice.
75fn first_text_value(tokens: &[Sexp]) -> Option<String> {
76    for tok in tokens {
77        if let Some(s) = tok.as_text() {
78            return Some(s.to_string());
79        }
80    }
81    None
82}
83
84/// Collect deps from the quoted list that follows `(define deps '(...))`.
85/// The `'` is parsed as `(quote inner-list)` by the shared parser.
86fn collect_dep_list(tokens: &[Sexp], kind: DepKind, deps: &mut Vec<DeclaredDep>) {
87    for tok in tokens {
88        match tok {
89            Sexp::List(inner) => {
90                // `'(...)` became `(quote (...))` — unwrap the quote.
91                if let Some(Sexp::Atom(head)) = inner.first()
92                    && head == "quote"
93                {
94                    if let Some(Sexp::List(actual)) = inner.get(1) {
95                        collect_dep_list_items(actual, kind, deps);
96                    }
97                    continue;
98                }
99                // Otherwise treat it as the list of items directly.
100                collect_dep_list_items(inner, kind, deps);
101            }
102            Sexp::Str(s) | Sexp::Atom(s) => {
103                if !s.is_empty() {
104                    deps.push(DeclaredDep {
105                        name: s.clone(),
106                        version_req: None,
107                        kind,
108                    });
109                }
110            }
111        }
112    }
113}
114
115fn collect_dep_list_items(items: &[Sexp], kind: DepKind, deps: &mut Vec<DeclaredDep>) {
116    for item in items {
117        match item {
118            Sexp::Str(s) => {
119                // String literals are package names.
120                if !s.is_empty() {
121                    deps.push(DeclaredDep {
122                        name: s.clone(),
123                        version_req: None,
124                        kind,
125                    });
126                }
127            }
128            Sexp::Atom(s) => {
129                // Bare symbols are also package names; skip keywords.
130                if !s.is_empty() && !s.starts_with('#') && !s.starts_with(':') {
131                    deps.push(DeclaredDep {
132                        name: s.clone(),
133                        version_req: None,
134                        kind,
135                    });
136                }
137            }
138            Sexp::List(sub) => {
139                // Check for nested `(quote ...)`.
140                if let Some(Sexp::Atom(head)) = sub.first()
141                    && head == "quote"
142                {
143                    if let Some(Sexp::List(actual)) = sub.get(1) {
144                        collect_dep_list_items(actual, kind, deps);
145                    }
146                    continue;
147                }
148                if let Some(dep) = parse_versioned_dep(sub, kind) {
149                    deps.push(dep);
150                }
151            }
152        }
153    }
154}
155
156fn parse_versioned_dep(sub: &[Sexp], kind: DepKind) -> Option<DeclaredDep> {
157    // Form: ("name" #:version "ver") or ("name" #:version "ver" ...)
158    let pkg_name = sub.first()?.as_text()?.to_string();
159    if pkg_name.is_empty() {
160        return None;
161    }
162
163    let version_req = kw_arg(sub, "#:version")
164        .and_then(|t| t.as_text())
165        .map(|s| s.to_string());
166
167    Some(DeclaredDep {
168        name: pkg_name,
169        version_req,
170        kind,
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::ManifestParser;
178
179    const SAMPLE: &str = r#"#lang info
180(define collection "my-package")
181(define version "1.0.0")
182(define deps '("base"
183               "racket-lib"
184               ("web-server-lib" #:version "1.0")
185               ("db-lib" #:version "1.1")))
186(define build-deps '("scribble-lib"
187                     "racket-doc"))
188"#;
189
190    #[test]
191    fn test_parse_info_rkt() {
192        let m = RacketInfoParser.parse(SAMPLE).unwrap();
193        assert_eq!(m.ecosystem, "racket");
194        assert_eq!(m.name.as_deref(), Some("my-package"));
195        assert_eq!(m.version.as_deref(), Some("1.0.0"));
196
197        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
198        assert!(names.contains(&"base"), "{names:?}");
199        assert!(names.contains(&"racket-lib"), "{names:?}");
200        assert!(names.contains(&"web-server-lib"), "{names:?}");
201        assert!(names.contains(&"db-lib"), "{names:?}");
202        assert!(names.contains(&"scribble-lib"), "{names:?}");
203        assert!(names.contains(&"racket-doc"), "{names:?}");
204
205        let web = m
206            .dependencies
207            .iter()
208            .find(|d| d.name == "web-server-lib")
209            .unwrap();
210        assert_eq!(web.version_req.as_deref(), Some("1.0"));
211        assert_eq!(web.kind, DepKind::Normal);
212
213        let scribble = m
214            .dependencies
215            .iter()
216            .find(|d| d.name == "scribble-lib")
217            .unwrap();
218        assert_eq!(scribble.kind, DepKind::Dev);
219    }
220
221    #[test]
222    fn test_define_name_fallback() {
223        let content = r#"#lang info
224(define name "alt-name")
225(define version "0.5.0")
226(define deps '())
227"#;
228        let m = RacketInfoParser.parse(content).unwrap();
229        assert_eq!(m.name.as_deref(), Some("alt-name"));
230    }
231}