Skip to main content

miniextendr_lint/
description.rs

1//! The R package's `DESCRIPTION`: its name and the packages it declares.
2//!
3//! Found from the Cargo manifest directory in the standard layout, where the
4//! crate lives at `<pkg>/src/rust` and `DESCRIPTION` at `<pkg>/DESCRIPTION`.
5//! Anywhere else (a plain Rust crate, a custom layout) there is none, and the
6//! rules that need it skip.
7
8use std::collections::HashSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// The `DESCRIPTION` fields that declare a package dependency.
13const DEPENDENCY_FIELDS: &[&str] = &["Depends", "Imports", "Suggests", "Enhances", "LinkingTo"];
14
15/// Packages shipped with R (base and recommended), which every R install has.
16const R_DISTRIBUTED_PACKAGES: &[&str] = &[
17    "base",
18    "compiler",
19    "datasets",
20    "graphics",
21    "grDevices",
22    "grid",
23    "methods",
24    "parallel",
25    "splines",
26    "stats",
27    "stats4",
28    "tcltk",
29    "tools",
30    "utils",
31    "boot",
32    "class",
33    "cluster",
34    "codetools",
35    "foreign",
36    "KernSmooth",
37    "lattice",
38    "MASS",
39    "Matrix",
40    "mgcv",
41    "nlme",
42    "nnet",
43    "rpart",
44    "spatial",
45    "survival",
46];
47
48/// What the lint reads from a `DESCRIPTION`.
49#[derive(#[automatically_derived]
impl ::core::clone::Clone for Description {
    #[inline]
    fn clone(&self) -> Self {
        Description {
            package: ::core::clone::Clone::clone(&self.package),
            declared: ::core::clone::Clone::clone(&self.declared),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for Description {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field2_finish(f, "Description",
            "package", &self.package, "declared", &&self.declared)
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for Description { }
#[automatically_derived]
impl ::core::cmp::PartialEq for Description {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.package == other.package && self.declared == other.declared
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for Description {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<String>;
        let _: ::core::cmp::AssertParamIsEq<HashSet<String>>;
    }
}Eq)]
50pub struct Description {
51    /// The `Package:` field.
52    pub package: String,
53    /// Every package in `Depends`, `Imports`, `Suggests`, `Enhances` and
54    /// `LinkingTo`, without version constraints.
55    pub declared: HashSet<String>,
56}
57
58impl Description {
59    /// Whether an R link into `pkg` can resolve: the package itself, a declared
60    /// dependency, or a package that ships with R.
61    pub fn resolves(&self, pkg: &str) -> bool {
62        pkg == self.package || self.declared.contains(pkg) || R_DISTRIBUTED_PACKAGES.contains(&pkg)
63    }
64
65    /// Parse `DESCRIPTION` text (Debian control format). `None` without a
66    /// `Package:` field, so a file that is not an R `DESCRIPTION` reads as none.
67    pub fn parse(text: &str) -> Option<Self> {
68        let mut package = None;
69        let mut declared = HashSet::new();
70        for (field, value) in fields(text) {
71            if field == "Package" {
72                package = Some(value.trim().to_string());
73            } else if DEPENDENCY_FIELDS.contains(&field.as_str()) {
74                declared.extend(
75                    value
76                        .split(',')
77                        .map(|dep| dep.split('(').next().unwrap_or("").trim())
78                        .filter(|dep| !dep.is_empty())
79                        .map(str::to_string),
80                );
81            }
82        }
83        Some(Self {
84            package: package.filter(|p| !p.is_empty())?,
85            declared,
86        })
87    }
88}
89
90/// The `DESCRIPTION` file of the R package whose Rust crate sits in
91/// `manifest_dir`, when there is one.
92pub fn locate(manifest_dir: &Path) -> Option<PathBuf> {
93    let path = manifest_dir.join("../../DESCRIPTION");
94    path.is_file().then_some(path)
95}
96
97/// Read and parse the `DESCRIPTION` of the package around `manifest_dir`.
98pub fn read(manifest_dir: &Path) -> Option<Description> {
99    Description::parse(&fs::read_to_string(locate(manifest_dir)?).ok()?)
100}
101
102/// `(field, value)` pairs; a line starting with whitespace continues the value.
103fn fields(text: &str) -> Vec<(String, String)> {
104    let mut out: Vec<(String, String)> = Vec::new();
105    for line in text.lines() {
106        if line.starts_with([' ', '\t']) {
107            if let Some((_, value)) = out.last_mut() {
108                value.push(' ');
109                value.push_str(line.trim());
110            }
111        } else if let Some((field, value)) = line.split_once(':') {
112            out.push((field.trim().to_string(), value.trim().to_string()));
113        }
114    }
115    out
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parses_package_and_dependencies_across_continuation_lines() {
124        let d = Description::parse(
125            "Package: mypkg\nImports: cli (>= 3.0),\n    rlang,\n    vctrs\nSuggests: testthat\nLinkingTo: cpp11\nTitle: x, y\n",
126        )
127        .unwrap();
128        assert_eq!(d.package, "mypkg");
129        for dep in ["cli", "rlang", "vctrs", "testthat", "cpp11"] {
130            assert!(d.declared.contains(dep), "{dep}");
131        }
132        assert!(!d.declared.contains("x"));
133        assert!(d.resolves("mypkg") && d.resolves("stats") && d.resolves("MASS"));
134        assert!(!d.resolves("Sources") && !d.resolves("dplyr"));
135    }
136
137    #[test]
138    fn a_file_without_a_package_field_is_none() {
139        assert_eq!(Description::parse("Title: x\n"), None);
140    }
141}