use rowan::ast::AstNode as _;
use crate::ast::RoxygenTag;
use crate::config::{CompatConfig, CompatVersion};
use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct Roxygen2Compat;
const EXAMPLES: &[Example] = &[Example {
caption: "An `@inheritParams` filter under a declared roxygen2 7.x \
(`roxygen2 = \"7.3.2\"` under `[compat]` in `arity.toml`):",
source: "#' Add one\n#' @inheritParams other -verbose\nadd_one <- function(x) x + 1\n",
}];
const SINGLE_LINE_TAGS: &[&str] = &[
"aliases",
"concept",
"encoding",
"exportClass",
"exportMethod",
"exportPattern",
"exportS3Method",
"importClassesFrom",
"importFrom",
"importMethodsFrom",
"include",
"includeRmd",
"inheritDotParams",
"inheritParams",
"inheritSection",
"keywords",
"method",
"name",
"order",
"rdname",
"template",
"useDynLib",
];
fn v800() -> CompatVersion {
CompatVersion::parse("8.0.0").expect("valid version")
}
impl Rule for Roxygen2Compat {
fn id(&self) -> &'static str {
"roxygen2-compat"
}
fn description(&self) -> &'static str {
"Flag documentation constructs mismatched with the project's roxygen2 \
version.\n\nThe targeted version comes from `[compat] roxygen2` in \
`arity.toml`, or from the package `DESCRIPTION` \
(`Config/roxygen2/version`, then the legacy `RoxygenNote`); without \
either, the rule stays silent. Targeting a version below 8.0.0 flags \
syntax only 8.0.0 understands—`@prop`, `@R6method`, `` `Rd expr` `` \
render-time code spans, `@inheritParams` argument filters (which \
older versions silently misread as argument names), and \
backtick-quoted names containing spaces. Targeting 8.0.0 or later \
flags a single-line tag (`@rdname`, `@importFrom`, …) whose value \
spans lines, which 8.0.0 warns about."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn doc_compat(&self) -> CompatConfig {
CompatConfig {
r: None,
roxygen2: Some("7.3.2".to_string()),
}
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::ROXYGEN_TAG, SyntaxKind::ROXYGEN_MD_CODE]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if el.kind() == SyntaxKind::ROXYGEN_MD_CODE {
check_md_code(el, ctx, sink);
return;
}
let Some(tag) = el.as_node().cloned().and_then(RoxygenTag::cast) else {
return;
};
let Some(name) = tag.name() else {
return;
};
let Some(floor) = ctx.roxygen2_compat_floor() else {
return;
};
if floor < v800() {
check_new_syntax(&tag, &name, &floor, sink);
} else if SINGLE_LINE_TAGS.binary_search(&name.as_str()).is_ok()
&& tag_has_continuation_lines(&tag)
{
push_finding(
sink,
tag_head_range(&tag),
format!(
"roxygen2 8.0.0 warns when `@{name}`'s value spans multiple \
lines (this project targets roxygen2 {floor})"
),
"Join the value onto the tag line.",
);
}
}
}
fn check_new_syntax(
tag: &RoxygenTag,
name: &str,
floor: &CompatVersion,
sink: &mut Vec<Diagnostic>,
) {
if matches!(name, "prop" | "R6method") {
push_finding(
sink,
tag_head_range(tag),
format!("`@{name}` requires roxygen2 >= 8.0.0 (this project targets {floor})"),
"Raise `[compat] roxygen2` (or re-document with roxygen2 8.0.0, which \
records its version in `DESCRIPTION`).",
);
return;
}
if name == "inheritParams"
&& let Some(text) = tag.text()
&& !text.text().trim().is_empty()
{
push_finding(
sink,
text.text_range(),
format!(
"`@inheritParams` argument filters require roxygen2 >= 8.0.0; \
older versions silently misread them as argument names \
(this project targets {floor})"
),
"Drop the filters or raise `[compat] roxygen2`.",
);
return;
}
if let Some(arg) = tag.arg() {
let text = arg.text();
if text.starts_with('`') && text.ends_with('`') && text.contains(char::is_whitespace) {
push_finding(
sink,
arg.text_range(),
format!(
"a backtick-quoted name with spaces requires roxygen2 >= 8.0.0 \
(this project targets {floor})"
),
"Rename the argument or raise `[compat] roxygen2`.",
);
}
}
}
fn check_md_code(el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let text = match el {
SyntaxElement::Token(t) => t.text().to_string(),
SyntaxElement::Node(n) => n.text().to_string(),
};
let inner = text.trim_start_matches('`');
if !inner.starts_with("Rd ") {
return;
}
let Some(floor) = ctx.roxygen2_compat_floor() else {
return;
};
if floor >= v800() {
return;
}
push_finding(
sink,
el.text_range(),
format!(
"an `Rd expr` render-time code span requires roxygen2 >= 8.0.0; \
older versions render it as literal code (this project targets {floor})"
),
"Write the Rd macro directly or raise `[compat] roxygen2`.",
);
}
fn tag_has_continuation_lines(tag: &RoxygenTag) -> bool {
let node = tag.syntax();
let Some(section) = node.parent() else {
return false;
};
section
.children()
.skip_while(|child| child != node)
.skip(1)
.any(|child| child.kind() == SyntaxKind::ROXYGEN_PARAGRAPH)
}
fn tag_head_range(tag: &RoxygenTag) -> rowan::TextRange {
let node_range = tag.syntax().text_range();
match (tag.at(), tag.name()) {
(Some(at), Some(name)) => rowan::TextRange::at(
at.text_range().start(),
(u32::from(at.text_range().len()) + name.len() as u32).into(),
),
_ => node_range,
}
}
fn push_finding(
sink: &mut Vec<Diagnostic>,
range: rowan::TextRange,
message: String,
suggestion: &str,
) {
sink.push(Diagnostic {
rule: "roxygen2-compat",
severity: Default::default(),
path: Default::default(),
range,
message: ViolationData::new("roxygen2-compat", message).with_suggestion(suggestion),
fix: None,
});
}