use rowan::TextRange;
use rowan::ast::AstNode as _;
use crate::ast::{RoxygenBlock, RoxygenTag};
use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::roxygen::{ParamDoc, documented_function, inherits_docs, param_doc};
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::syntax::{SyntaxElement, SyntaxKind};
pub struct RoxygenParam;
const EXAMPLES: &[Example] = &[Example {
caption: "`y` is undocumented and `@param z` matches nothing:",
source: "#' Add two numbers\n#' @param x The first number.\n#' @param z The other one.\n#' @export\nadd <- function(x, y) x + y\n",
}];
fn tag_head_range(tag: &RoxygenTag) -> Option<TextRange> {
let start = tag.at()?.text_range().start();
let end = tag
.arg()
.map(|arg| arg.text_range().end())
.or_else(|| Some(tag.syntax().text_range().end()))?;
Some(TextRange::new(start, end))
}
impl Rule for RoxygenParam {
fn id(&self) -> &'static str {
"roxygen-param"
}
fn description(&self) -> &'static str {
"Flag `@param` documentation that does not match the documented \
function.\
\n\nFour shapes are reported: a formal argument with no `@param`, a \
`@param` naming a nonexistent formal (often a rename that never \
reached the docs), a name documented twice, and a `@param` missing \
its name or description. Blocks that inherit or merge documentation \
(`@inheritParams`, `@rdname`, `@describeIn`, `@template`) are exempt \
from the coverage checks; duplicates are always reported."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::ROXYGEN_BLOCK]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let Some(block) = el.as_node().cloned().and_then(RoxygenBlock::cast) else {
return;
};
let function = documented_function(&block);
let judge_coverage = function.is_some() && !inherits_docs(&block);
let formals = function.map(|f| f.params()).unwrap_or_default();
let mut documented: Vec<smol_str::SmolStr> = Vec::new();
let mut any_unknown = false;
for section in block.sections() {
let Some(doc) = param_doc(§ion) else {
continue;
};
let tag = section.tag().expect("param sections have a tag");
match doc {
ParamDoc::Unknown => any_unknown = true,
ParamDoc::Empty => {
let Some(range) = tag_head_range(&tag) else {
continue;
};
sink.push(diagnostic(
range,
"`@param` requires a name and description".to_string(),
"Write `@param <name> <description>`.",
));
}
ParamDoc::Named {
names,
has_description,
} => {
if !has_description {
let Some(range) = tag_head_range(&tag) else {
continue;
};
let list = names
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ");
sink.push(diagnostic(
range,
format!("`@param {list}` has no description"),
"Describe the argument after its name.",
));
}
for (name, range) in names {
if documented.contains(&name) {
sink.push(diagnostic(
range,
format!("`@param {name}` is documented more than once"),
"Remove or merge the duplicate entry.",
));
continue;
}
if judge_coverage && !formals.iter().any(|p| p.name == name) {
sink.push(diagnostic(
range,
format!(
"`@param {name}` does not match a formal argument of the \
documented function"
),
"Rename it to a formal argument or remove it.",
));
}
documented.push(name);
}
}
}
}
if !judge_coverage || any_unknown {
return;
}
for formal in formals {
if !documented.contains(&formal.name) {
sink.push(diagnostic(
formal.name_token.text_range(),
format!(
"formal argument `{}` is not documented with `@param`",
formal.name
),
"Add `@param` for it (or `@inheritParams` a function that documents it).",
));
}
}
}
}
fn diagnostic(range: TextRange, body: String, suggestion: &str) -> Diagnostic {
Diagnostic {
rule: "roxygen-param",
severity: Default::default(),
path: Default::default(),
range,
message: ViolationData::new("roxygen-param", body).with_suggestion(suggestion),
fix: None,
}
}