Skip to main content

normalize_manifest/
r_description.rs

1//! Parser for R `DESCRIPTION` files (CRAN/DCF format).
2//!
3//! DCF (Debian Control File) format: `Key: value` with continuation lines
4//! starting with whitespace. Extracts:
5//! - `Imports:` → `DepKind::Normal`
6//! - `Depends:` → `DepKind::Normal` (skips the `R` runtime entry)
7//! - `Suggests:` → `DepKind::Dev`
8//! - `LinkingTo:` → `DepKind::Build`
9//!
10//! Version constraints like `dplyr (>= 1.0.0)` are split into name + version_req.
11
12use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
13
14/// Parser for R `DESCRIPTION` files.
15pub struct RDescriptionParser;
16
17impl ManifestParser for RDescriptionParser {
18    fn filename(&self) -> &'static str {
19        "DESCRIPTION"
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        #[derive(Clone, Copy, PartialEq)]
28        enum Field {
29            None,
30            Imports,
31            Suggests,
32            Depends,
33            LinkingTo,
34        }
35
36        let mut current_field = Field::None;
37        // Accumulated value text across continuation lines
38        let mut field_value = String::new();
39
40        let flush = |field: Field, value: &str, deps: &mut Vec<DeclaredDep>| {
41            if field == Field::None || value.is_empty() {
42                return;
43            }
44            let kind = match field {
45                Field::Imports => DepKind::Normal,
46                Field::Depends => DepKind::Normal,
47                Field::Suggests => DepKind::Dev,
48                Field::LinkingTo => DepKind::Build,
49                Field::None => return,
50            };
51            for entry in value.split(',') {
52                let entry = entry.trim();
53                if entry.is_empty() {
54                    continue;
55                }
56                let dep_entry = parse_r_dep_entry(entry);
57                if dep_entry.pkg_name.is_empty() || dep_entry.pkg_name == "R" {
58                    continue;
59                }
60                deps.push(DeclaredDep {
61                    name: dep_entry.pkg_name,
62                    version_req: dep_entry.version_req,
63                    kind,
64                });
65            }
66        };
67
68        for line in content.lines() {
69            // Continuation line: starts with whitespace
70            if line.starts_with(' ') || line.starts_with('\t') {
71                if current_field != Field::None {
72                    if !field_value.is_empty() {
73                        field_value.push(',');
74                    }
75                    field_value.push_str(line.trim());
76                }
77                continue;
78            }
79
80            // New field: flush previous
81            flush(current_field, &field_value, &mut deps);
82            field_value.clear();
83            current_field = Field::None;
84
85            let trimmed = line.trim();
86            if trimmed.is_empty() {
87                continue;
88            }
89
90            if let Some(colon) = trimmed.find(':') {
91                let key = trimmed[..colon].trim();
92                let val = trimmed[colon + 1..].trim();
93
94                match key {
95                    "Package" => name = Some(val.to_string()),
96                    "Version" => version = Some(val.to_string()),
97                    "Imports" => {
98                        current_field = Field::Imports;
99                        field_value = val.to_string();
100                    }
101                    "Suggests" => {
102                        current_field = Field::Suggests;
103                        field_value = val.to_string();
104                    }
105                    "Depends" => {
106                        current_field = Field::Depends;
107                        field_value = val.to_string();
108                    }
109                    "LinkingTo" => {
110                        current_field = Field::LinkingTo;
111                        field_value = val.to_string();
112                    }
113                    _ => {}
114                }
115            }
116        }
117
118        // Flush last field
119        flush(current_field, &field_value, &mut deps);
120
121        Ok(ParsedManifest {
122            ecosystem: "cran",
123            name,
124            version,
125            dependencies: deps,
126        })
127    }
128}
129
130struct RDepEntry {
131    pkg_name: String,
132    version_req: Option<String>,
133}
134
135/// Parse `dplyr (>= 1.0.0)` → name + optional version.
136/// Also handles bare `ggplot2` → name with no version.
137fn parse_r_dep_entry(s: &str) -> RDepEntry {
138    if let Some(paren) = s.find('(') {
139        let pkg_name = s[..paren].trim().to_string();
140        let rest = s[paren + 1..].trim();
141        let ver = rest.trim_end_matches(')').trim().to_string();
142        let version_req = if ver.is_empty() { None } else { Some(ver) };
143        RDepEntry {
144            pkg_name,
145            version_req,
146        }
147    } else {
148        RDepEntry {
149            pkg_name: s.trim().to_string(),
150            version_req: None,
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::ManifestParser;
159
160    #[test]
161    fn test_parse_r_description() {
162        let content = r#"Package: mypackage
163Title: My R Package
164Version: 1.0.0
165Authors@R: person("Alice", "Smith", role = c("aut", "cre"))
166Description: Does things.
167Imports:
168    dplyr (>= 1.0.0),
169    ggplot2,
170    stringr (>= 1.4.0)
171Suggests:
172    testthat (>= 3.0.0),
173    knitr,
174    rmarkdown
175Depends:
176    R (>= 4.0.0)
177LinkingTo:
178    Rcpp
179License: MIT
180"#;
181        let m = RDescriptionParser.parse(content).unwrap();
182        assert_eq!(m.ecosystem, "cran");
183        assert_eq!(m.name.as_deref(), Some("mypackage"));
184        assert_eq!(m.version.as_deref(), Some("1.0.0"));
185
186        let dplyr = m.dependencies.iter().find(|d| d.name == "dplyr").unwrap();
187        assert_eq!(dplyr.kind, DepKind::Normal);
188        assert_eq!(dplyr.version_req.as_deref(), Some(">= 1.0.0"));
189
190        let ggplot = m.dependencies.iter().find(|d| d.name == "ggplot2").unwrap();
191        assert_eq!(ggplot.kind, DepKind::Normal);
192        assert!(ggplot.version_req.is_none());
193
194        let testthat = m
195            .dependencies
196            .iter()
197            .find(|d| d.name == "testthat")
198            .unwrap();
199        assert_eq!(testthat.kind, DepKind::Dev);
200        assert_eq!(testthat.version_req.as_deref(), Some(">= 3.0.0"));
201
202        let rcpp = m.dependencies.iter().find(|d| d.name == "Rcpp").unwrap();
203        assert_eq!(rcpp.kind, DepKind::Build);
204
205        // R itself should be skipped
206        assert!(!m.dependencies.iter().any(|d| d.name == "R"));
207    }
208
209    #[test]
210    fn test_inline_imports() {
211        // Imports on same line as key (no continuation)
212        let content = "Package: tiny\nVersion: 0.0.1\nImports: data.table, jsonlite\n";
213        let m = RDescriptionParser.parse(content).unwrap();
214        assert_eq!(m.dependencies.len(), 2);
215        assert!(m.dependencies.iter().any(|d| d.name == "data.table"));
216        assert!(m.dependencies.iter().any(|d| d.name == "jsonlite"));
217    }
218}