use rowan::ast::AstNode as _;
use crate::ast::{BinaryExpr, CallExpr};
use crate::linter::diagnostic::{Diagnostic, ViolationData};
use crate::linter::rules::matchers;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::semantic::symbols::is_implicitly_available;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxToken};
pub struct UndeclaredDependency;
const EXAMPLES: &[Example] = &[Example {
caption: "In `R/` of a package whose `DESCRIPTION` declares only `Imports: rlang`:",
source: "summarize <- function(data) {\n dplyr::group_by(data, id)\n}\n",
}];
const EXAMPLE_PACKAGE: &[(&str, &str)] = &[(
"DESCRIPTION",
"Package: mypkg\nVersion: 0.1.0\nImports: rlang\n",
)];
impl Rule for UndeclaredDependency {
fn id(&self) -> &'static str {
"undeclared-dependency"
}
fn description(&self) -> &'static str {
"Flag package code that reaches a package its `DESCRIPTION` never \
declares.\n\n`dplyr::filter()` in `R/` works on the author's machine \
because `dplyr` happens to be installed there; on a clean machine it \
is a load-time error, and `R CMD check` reports it. Declaring the \
dependency is what causes it to be installed, so leaving it out is a \
bug that only ever surfaces somewhere else.\n\nThe rule matches \
`pkg::name`, `pkg:::name`, and the package argument of `library`, \
`require`, `requireNamespace`, and `loadNamespace`—at any depth, since \
the conditional-dependency idiom lives inside a function body.\n\nThe \
exempt set is R's own: everything declared in any of `Depends`, \
`Imports`, `Suggests`, `LinkingTo`, or `Enhances`, the package's own \
name, and the packages R ships at base priority—except `methods` and \
`stats4`, which `R CMD check` still expects a package to declare. Only \
files directly in `R/` are checked: a test's or a vignette's \
dependencies belong in `Suggests`, and R does not scan those \
directories for this check either.\n\nThere is no autofix. A fix is an \
edit to the file the finding is in, and the repair here is a line in \
`DESCRIPTION`."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn doc_package(&self) -> &'static [(&'static str, &'static str)] {
EXAMPLE_PACKAGE
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::BINARY_EXPR, SyntaxKind::CALL_EXPR]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
if !ctx.is_package_r_source() {
return;
}
let facts = ctx.package.expect("gated by is_package_r_source");
let Some(own) = facts.package.as_deref() else {
return;
};
let Some(node) = el.as_node() else {
return;
};
let (name, token, how) = match el.kind() {
SyntaxKind::BINARY_EXPR => {
let Some(access) =
BinaryExpr::cast(node.clone()).and_then(|b| b.namespace_access())
else {
return;
};
(access.package, access.package_token, How::Qualified)
}
SyntaxKind::CALL_EXPR => {
let Some(call) = CallExpr::cast(node.clone()) else {
return;
};
let Some((name, token)) = matchers::package_load_arg(&call) else {
return;
};
(name, token, How::Attached)
}
_ => return,
};
if name == own || is_implicitly_available(&name) {
return;
}
if facts.dependencies.iter().any(|d| d.name == name) {
return;
}
sink.push(diagnostic(&name, &token, how));
}
}
#[derive(Clone, Copy)]
enum How {
Qualified,
Attached,
}
fn diagnostic(name: &str, token: &SyntaxToken, how: How) -> Diagnostic {
let suggestion = match how {
How::Qualified => format!("Add `{name}` to `Imports:` in DESCRIPTION."),
How::Attached => format!(
"Add `{name}` to `Imports:` in DESCRIPTION, and reach it with `{name}::` \
rather than attaching it from package code."
),
};
Diagnostic {
rule: "undeclared-dependency",
severity: Default::default(),
path: Default::default(),
range: token.text_range(),
message: ViolationData::new(
"undeclared-dependency",
format!("package `{name}` is used here but is not declared in DESCRIPTION"),
)
.with_suggestion(suggestion),
fix: None,
}
}