use std::collections::HashSet;
use rowan::ast::AstNode as _;
use smol_str::SmolStr;
use crate::ast::{AssignmentExpr, RoxygenBlock};
use crate::linter::diagnostic::{Diagnostic, Severity, ViolationData};
use crate::linter::rules::roxygen::documented_function;
use crate::linter::rules::{Example, Rule, RuleContext};
use crate::semantic::ScopeKind;
use crate::syntax::{SyntaxKind, SyntaxNode};
const EXAMPLES: &[Example] = &[Example {
caption: "`add_one` is exported but never called anywhere in the package:",
source: "#' Add one\n#'\n#' @export\nadd_one <- function(x) {\n x + 1\n}\n",
}];
pub struct UnusedFunction;
impl Rule for UnusedFunction {
fn id(&self) -> &'static str {
"unused-function"
}
fn description(&self) -> &'static str {
"Flag an exported function that nothing in the project calls. The \
complement of `unused-binding`, which stays quiet on public API: a \
function is reported here only when it is declared exported (a \
roxygen `@export`, or a NAMESPACE `export()`) *and* no file that can \
see it reads it. S3 methods are exempt — dispatch reaches them \
without a direct call, so having no caller says nothing about them. \
Disabled by default, since a library's exported functions are meant \
to be called from outside the project."
}
fn examples(&self) -> &'static [Example] {
EXAMPLES
}
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn default_enabled(&self) -> bool {
false
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let roxygen_exports = roxygen_exported_names(ctx.root);
sink.extend(
ctx.model
.unused_local_bindings()
.filter(|id| {
let b = ctx.model.binding(*id);
if ctx.model.scope(b.scope).kind != ScopeKind::File {
return false;
}
if !binds_a_function(ctx.root, b.def_range) {
return false;
}
if ctx.project.is_some_and(|p| p.read_elsewhere(&b.name)) {
return false;
}
if is_s3_method(ctx, &b.name) {
return false;
}
roxygen_exports.contains(&b.name)
|| ctx
.project
.is_some_and(|p| p.exported_by_namespace(&b.name))
})
.map(|id| {
let b = ctx.model.binding(id);
Diagnostic {
rule: "unused-function",
severity: Default::default(),
path: Default::default(),
range: b.def_range,
message: ViolationData::new(
"unused-function",
format!("exported function `{}` is never called", b.name),
)
.with_suggestion(
"Remove it, or stop exporting it if it is not part of the public API.",
),
fix: None,
}
}),
);
}
}
fn is_s3_method(ctx: &RuleContext<'_>, name: &str) -> bool {
match ctx.project {
Some(project) => project.is_s3_method(name),
None => name.contains('.'),
}
}
fn binds_a_function(root: &SyntaxNode, def_range: rowan::TextRange) -> bool {
let Some(token) = root.covering_element(def_range).into_token() else {
return false;
};
let Some(assign) = token.parent().and_then(AssignmentExpr::cast) else {
return false;
};
assign
.value_element()
.and_then(|el| el.into_node())
.is_some_and(|value| value.kind() == SyntaxKind::FUNCTION_EXPR)
}
fn roxygen_exported_names(root: &SyntaxNode) -> HashSet<SmolStr> {
root.children()
.filter_map(RoxygenBlock::cast)
.filter(|block| block.has_tag("export"))
.filter_map(|block| {
let function = documented_function(&block)?;
let assign = function.syntax().parent().and_then(AssignmentExpr::cast)?;
assign.target_name()
})
.collect()
}