use std::path::Path;
use crate::ast::{Arg, AstNode, CallExpr, HasArgList};
use crate::config::CompatVersion;
use crate::parser::parse;
use crate::project::scope::package_root;
use crate::rindex::harvest::parse_dcf;
use crate::syntax::SyntaxKind;
pub fn roxygen_markdown_default(root: &Path) -> bool {
let desc = std::fs::read_to_string(root.join("DESCRIPTION"))
.ok()
.and_then(|text| roxygen_field(&text))
.and_then(|expr| markdown_from_r_text(&expr));
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> {
let text = std::fs::read_to_string(root.join("DESCRIPTION")).ok()?;
parse_dcf(&text)
.into_iter()
.find(|(key, _)| key == "Package")
.map(|(_, value)| value)
.filter(|name| !name.is_empty())
}
pub fn package_name_for_file(path: &Path) -> Option<String> {
package_root(path).and_then(|root| package_name(&root))
}
fn roxygen_field(description: &str) -> Option<String> {
parse_dcf(description)
.into_iter()
.find(|(key, _)| key == "Roxygen")
.map(|(_, value)| value)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DescriptionCompat {
pub r: Option<CompatVersion>,
pub roxygen2: Option<CompatVersion>,
}
pub fn description_compat(root: &Path) -> DescriptionCompat {
let Ok(text) = std::fs::read_to_string(root.join("DESCRIPTION")) else {
return DescriptionCompat::default();
};
let fields = parse_dcf(&text);
let get = |name: &str| {
fields
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
};
DescriptionCompat {
r: get("Depends").and_then(r_depends_floor),
roxygen2: get("Config/roxygen2/version")
.or_else(|| get("RoxygenNote"))
.and_then(|v| CompatVersion::parse(v.trim())),
}
}
pub fn description_compat_for_file(path: &Path) -> DescriptionCompat {
package_root(path)
.map(|root| description_compat(&root))
.unwrap_or_default()
}
fn r_depends_floor(depends: &str) -> Option<CompatVersion> {
for entry in depends.split(',') {
let entry = entry.trim();
let (name, constraint) = match entry.find('(') {
Some(open) => (
entry[..open].trim(),
entry[open + 1..].trim_end().strip_suffix(')'),
),
None => (entry, None),
};
if name != "R" {
continue;
}
let constraint = constraint?.trim();
let version = constraint
.strip_prefix(">=")
.or_else(|| constraint.strip_prefix('>'))?;
return CompatVersion::parse(version.trim());
}
None
}
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| r_depends_floor(s);
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 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));
}
}