use std::collections::BTreeSet;
use std::path::Path;
use crate::ast::{Arg, AstNode, CallExpr, HasArgList};
use crate::config::CompatVersion;
use crate::dcf::deps::dependency_entries;
use crate::parser::parse;
use crate::project::scope::package_root;
use crate::syntax::SyntaxKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DependencyField {
Depends,
Imports,
Suggests,
LinkingTo,
Enhances,
}
impl DependencyField {
pub const ALL: [DependencyField; 5] = [
DependencyField::Depends,
DependencyField::Imports,
DependencyField::Suggests,
DependencyField::LinkingTo,
DependencyField::Enhances,
];
pub fn name(self) -> &'static str {
match self {
DependencyField::Depends => "Depends",
DependencyField::Imports => "Imports",
DependencyField::Suggests => "Suggests",
DependencyField::LinkingTo => "LinkingTo",
DependencyField::Enhances => "Enhances",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dependency {
pub field: DependencyField,
pub name: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DescriptionFacts {
pub package: Option<String>,
pub dependencies: Vec<Dependency>,
pub compat: DescriptionCompat,
pub roxygen: Option<String>,
pub collate: BTreeSet<String>,
}
impl DescriptionFacts {
pub fn from_text(text: &str) -> Self {
Self::from_document(&crate::dcf::parse(text).document())
}
pub fn from_document(document: &crate::dcf::Document) -> Self {
let get = |name: &str| document.field(name).map(|field| field.folded_value());
let mut dependencies = Vec::new();
let mut r_floor = None;
let mut seen_r = false;
for field in DependencyField::ALL {
let Some(node) = document.field(field.name()) else {
continue;
};
for entry in dependency_entries(&node) {
if entry.name == "R" {
if field == DependencyField::Depends && !seen_r {
seen_r = true;
r_floor = entry
.lower_bound()
.and_then(|c| CompatVersion::parse(c.version.trim()));
}
continue;
}
dependencies.push(Dependency {
field,
name: entry.name.to_string(),
version: entry.lower_bound().map(|c| c.version.to_string()),
});
}
}
let mut collate = BTreeSet::new();
for field in document.fields() {
if !field.name().starts_with("Collate") {
continue;
}
for entry in field.folded_value().split_whitespace() {
let name = entry.trim_matches(['\'', '"']);
if !name.is_empty() {
collate.insert(name.to_string());
}
}
}
DescriptionFacts {
package: get("Package").filter(|name| !name.is_empty()),
dependencies,
compat: DescriptionCompat {
r: r_floor,
roxygen2: get("Config/roxygen2/version")
.or_else(|| get("RoxygenNote"))
.and_then(|v| CompatVersion::parse(v.trim())),
},
roxygen: get("Roxygen"),
collate,
}
}
pub fn in_field(&self, field: DependencyField) -> impl Iterator<Item = &Dependency> {
self.dependencies.iter().filter(move |d| d.field == field)
}
pub fn attached_packages(&self) -> BTreeSet<String> {
self.in_field(DependencyField::Depends)
.map(|d| d.name.clone())
.collect()
}
pub fn declared_packages(&self) -> BTreeSet<String> {
self.dependencies.iter().map(|d| d.name.clone()).collect()
}
}
pub fn roxygen_markdown_default(root: &Path) -> bool {
let desc = facts_at(root)
.roxygen
.as_deref()
.and_then(markdown_from_r_text);
let meta = std::fs::read_to_string(root.join("man/roxygen/meta.R"))
.ok()
.and_then(|text| markdown_from_r_text(&text));
meta.or(desc).unwrap_or(false)
}
pub fn roxygen_markdown_default_for_file(path: &Path) -> bool {
package_root(path).is_some_and(|root| roxygen_markdown_default(&root))
}
pub fn roxygen_markdown_default_for_dir(dir: &Path) -> bool {
roxygen_markdown_default_for_file(&dir.join("_stdin_.R"))
}
#[derive(Debug, Default)]
pub struct MarkdownDefaultResolver {
by_dir: std::collections::HashMap<std::path::PathBuf, bool>,
}
impl MarkdownDefaultResolver {
pub fn new() -> Self {
Self::default()
}
pub fn resolve(&mut self, file: &Path) -> bool {
match file.parent() {
Some(dir) => *self
.by_dir
.entry(dir.to_path_buf())
.or_insert_with(|| roxygen_markdown_default_for_file(file)),
None => false,
}
}
}
pub fn package_name(root: &Path) -> Option<String> {
facts_at(root).package
}
pub fn facts_at(root: &Path) -> DescriptionFacts {
match std::fs::read_to_string(root.join("DESCRIPTION")) {
Ok(text) => DescriptionFacts::from_text(&text),
Err(_) => DescriptionFacts::default(),
}
}
pub fn package_name_for_file(path: &Path) -> Option<String> {
package_root(path).and_then(|root| package_name(&root))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DescriptionCompat {
pub r: Option<CompatVersion>,
pub roxygen2: Option<CompatVersion>,
}
pub fn description_compat(root: &Path) -> DescriptionCompat {
facts_at(root).compat
}
pub fn description_compat_for_file(path: &Path) -> DescriptionCompat {
package_root(path)
.map(|root| description_compat(&root))
.unwrap_or_default()
}
#[derive(Debug, Default)]
pub struct DescriptionCache {
by_root: std::collections::HashMap<std::path::PathBuf, std::sync::Arc<DescriptionFacts>>,
}
impl DescriptionCache {
pub fn new() -> Self {
Self::default()
}
pub fn at(&mut self, root: &Path) -> std::sync::Arc<DescriptionFacts> {
self.by_root
.entry(root.to_path_buf())
.or_insert_with(|| std::sync::Arc::new(facts_at(root)))
.clone()
}
pub fn for_file(&mut self, file: &Path) -> std::sync::Arc<DescriptionFacts> {
match package_root(file) {
Some(root) => self.at(&root),
None => std::sync::Arc::new(DescriptionFacts::default()),
}
}
}
fn markdown_from_r_text(text: &str) -> Option<bool> {
let output = parse(text);
if !output.diagnostics.is_empty() {
return None;
}
let last = output
.cst
.children_with_tokens()
.filter(|el| {
!matches!(
el.kind(),
SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::COMMENT
| SyntaxKind::SEMICOLON
)
})
.last()?;
let last = CallExpr::cast(last.into_node()?)?;
if last.callee_name().as_deref() != Some("list") {
return None;
}
last.args()
.filter(|arg| arg.name().as_deref() == Some("markdown"))
.last()
.and_then(|arg| literal_logical(&arg))
}
fn literal_logical(arg: &Arg) -> Option<bool> {
let value = arg.value()?;
let token = value.into_token()?;
match token.text() {
"TRUE" => Some(true),
"FALSE" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn package(description: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("R")).expect("R/");
std::fs::write(dir.path().join("R/a.R"), "NULL\n").expect("a.R");
std::fs::write(dir.path().join("DESCRIPTION"), description).expect("DESCRIPTION");
dir
}
fn write_meta(dir: &tempfile::TempDir, source: &str) {
let meta_dir = dir.path().join("man/roxygen");
std::fs::create_dir_all(&meta_dir).expect("man/roxygen/");
std::fs::write(meta_dir.join("meta.R"), source).expect("meta.R");
}
#[test]
fn description_compat_reads_r_floor_and_roxygen2_version() {
let dir = package(
"Package: mypkg\n\
Depends: methods, R (>= 4.1.0), stats\n\
RoxygenNote: 7.3.2\n",
);
let compat = description_compat(dir.path());
assert_eq!(compat.r, CompatVersion::parse("4.1.0"));
assert_eq!(compat.roxygen2, CompatVersion::parse("7.3.2"));
assert_eq!(
description_compat_for_file(&dir.path().join("R/a.R")),
compat
);
let loose = tempfile::tempdir().expect("tempdir");
assert_eq!(
description_compat_for_file(&loose.path().join("script.R")),
DescriptionCompat::default()
);
}
#[test]
fn description_compat_prefers_the_modern_roxygen2_field() {
let dir = package(
"Package: mypkg\n\
Config/roxygen2/version: 8.0.0\n\
RoxygenNote: 7.3.2\n",
);
assert_eq!(
description_compat(dir.path()).roxygen2,
CompatVersion::parse("8.0.0")
);
}
#[test]
fn r_depends_floor_parses_constraint_shapes() {
let floor = |s: &str| {
DescriptionFacts::from_text(&format!("Package: p\nDepends: {s}\n"))
.compat
.r
};
assert_eq!(floor("R (>= 4.1)"), CompatVersion::parse("4.1"));
assert_eq!(floor("R (> 4.0.5)"), CompatVersion::parse("4.0.5"));
assert_eq!(
floor("stats, R(>=3.5.0), utils"),
CompatVersion::parse("3.5.0")
);
assert_eq!(floor("R"), None);
assert_eq!(floor("R (== 4.1)"), None);
assert_eq!(floor("Rcpp (>= 1.0)"), None);
assert_eq!(floor(""), None);
}
#[test]
fn r_is_never_a_dependency() {
let facts = DescriptionFacts::from_text("Package: p\nDepends: R (>= 4.1), stats\n");
assert_eq!(facts.attached_packages(), ["stats".to_string()].into());
assert_eq!(facts.declared_packages(), ["stats".to_string()].into());
assert_eq!(facts.compat.r, CompatVersion::parse("4.1"));
}
#[test]
fn every_dependency_field_is_parsed() {
let facts = DescriptionFacts::from_text(
"Package: p\n\
Depends: R (>= 4.1), stats\n\
Imports: dplyr (>= 1.0.0), rlang\n\
Suggests: testthat\n\
LinkingTo: cpp11\n\
Enhances: data.table\n",
);
let named = |field: DependencyField| {
facts
.in_field(field)
.map(|d| d.name.as_str())
.collect::<Vec<_>>()
};
assert_eq!(named(DependencyField::Depends), ["stats"]);
assert_eq!(named(DependencyField::Imports), ["dplyr", "rlang"]);
assert_eq!(named(DependencyField::Suggests), ["testthat"]);
assert_eq!(named(DependencyField::LinkingTo), ["cpp11"]);
assert_eq!(named(DependencyField::Enhances), ["data.table"]);
assert_eq!(facts.attached_packages(), ["stats".to_string()].into());
assert_eq!(facts.declared_packages().len(), 6);
let dplyr = facts
.in_field(DependencyField::Imports)
.next()
.expect("dplyr");
assert_eq!(dplyr.version.as_deref(), Some("1.0.0"));
}
#[test]
fn facts_collect_collate_and_roxygen() {
let facts = DescriptionFacts::from_text(
"Package: p\n\
Roxygen: list(markdown = TRUE)\n\
Collate: 'a.R' b.R\n\
Collate@windows: c.R\n",
);
assert_eq!(facts.package.as_deref(), Some("p"));
assert_eq!(facts.roxygen.as_deref(), Some("list(markdown = TRUE)"));
assert_eq!(
facts.collate,
["a.R".to_string(), "b.R".to_string(), "c.R".to_string()].into()
);
}
#[test]
fn facts_are_all_default_without_a_description() {
let loose = tempfile::tempdir().expect("tempdir");
assert_eq!(facts_at(loose.path()), DescriptionFacts::default());
}
#[test]
fn description_cache_reads_once_per_root() {
let dir = package("Package: mypkg\nImports: dplyr\n");
let mut cache = DescriptionCache::new();
let first = cache.for_file(&dir.path().join("R/a.R"));
std::fs::remove_file(dir.path().join("DESCRIPTION")).expect("remove");
let second = cache.at(dir.path());
assert_eq!(first.package.as_deref(), Some("mypkg"));
assert_eq!(first, second);
}
#[test]
fn package_name_from_description() {
let dir = package("Package: mypkg\nVersion: 1.0\n");
assert_eq!(package_name(dir.path()).as_deref(), Some("mypkg"));
assert_eq!(
package_name_for_file(&dir.path().join("R/a.R")).as_deref(),
Some("mypkg")
);
assert_eq!(
package_name_for_file(&dir.path().join("tests/testthat/test-a.R")).as_deref(),
Some("mypkg")
);
}
#[test]
fn package_name_is_none_without_a_package() {
let loose = tempfile::tempdir().expect("tempdir");
assert_eq!(package_name(loose.path()), None);
assert_eq!(package_name_for_file(&loose.path().join("a.R")), None);
let dir = package("Version: 1.0\n");
assert_eq!(package_name(dir.path()), None);
}
#[test]
fn markdown_true_from_roxygen_field() {
let dir = package("Package: p\nRoxygen: list(markdown = TRUE)\n");
assert!(roxygen_markdown_default(dir.path()));
}
#[test]
fn markdown_defaults_off_without_field() {
let dir = package("Package: p\n");
assert!(!roxygen_markdown_default(dir.path()));
}
#[test]
fn markdown_false_is_explicit_off() {
let dir = package("Package: p\nRoxygen: list(markdown = FALSE)\n");
assert!(!roxygen_markdown_default(dir.path()));
}
#[test]
fn field_with_more_options_and_continuation() {
let dir = package(
"Package: p\nRoxygen: list(load = \"installed\",\n markdown = TRUE)\nDepends: R\n",
);
assert!(roxygen_markdown_default(dir.path()));
}
#[test]
fn non_literal_markdown_value_is_unknown() {
let dir = package("Package: p\nRoxygen: list(markdown = flag)\n");
assert!(!roxygen_markdown_default(dir.path()));
}
#[test]
fn non_list_field_is_unknown() {
let dir = package("Package: p\nRoxygen: make_options()\n");
assert!(!roxygen_markdown_default(dir.path()));
}
#[test]
fn meta_overrides_description_on() {
let dir = package("Package: p\nRoxygen: list(markdown = FALSE)\n");
write_meta(&dir, "list(markdown = TRUE)\n");
assert!(roxygen_markdown_default(dir.path()));
}
#[test]
fn meta_overrides_description_off() {
let dir = package("Package: p\nRoxygen: list(markdown = TRUE)\n");
write_meta(&dir, "list(markdown = FALSE)\n");
assert!(!roxygen_markdown_default(dir.path()));
}
#[test]
fn meta_last_expression_wins_after_other_statements() {
let dir = package("Package: p\n");
write_meta(&dir, "x <- 1\nlist(markdown = TRUE)\n");
assert!(roxygen_markdown_default(dir.path()));
}
#[test]
fn meta_trailing_atom_is_not_the_list() {
let dir = package("Package: p\nRoxygen: list(markdown = FALSE)\n");
write_meta(&dir, "list(markdown = TRUE)\nx\n");
assert!(!roxygen_markdown_default(dir.path()));
}
#[test]
fn unresolvable_meta_defers_to_description() {
let dir = package("Package: p\nRoxygen: list(markdown = TRUE)\n");
write_meta(&dir, "build_meta()\n");
assert!(roxygen_markdown_default(dir.path()));
}
#[test]
fn meta_list_without_markdown_defers_to_description() {
let dir = package("Package: p\nRoxygen: list(markdown = TRUE)\n");
write_meta(&dir, "list(knitr_chunk_options = NULL)\n");
assert!(roxygen_markdown_default(dir.path()));
}
#[test]
fn for_file_resolves_via_package_root() {
let dir = package("Package: p\nRoxygen: list(markdown = TRUE)\n");
assert!(roxygen_markdown_default_for_file(&dir.path().join("R/a.R")));
}
#[test]
fn for_file_outside_a_package_is_off() {
let dir = tempfile::tempdir().expect("tempdir");
let loose = dir.path().join("loose.R");
std::fs::write(&loose, "NULL\n").expect("loose.R");
assert!(!roxygen_markdown_default_for_file(&loose));
}
}