use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::OnceLock;
use rowan::ast::AstNode as _;
use crate::ast::{BinaryExpr, CallExpr};
use crate::config::{CompatConfig, CompatVersion, LintConfig, RulesConfig};
use crate::dcf;
use crate::linter::rules::roxygen::RoxygenTopics;
use crate::project::description::{DescriptionCompat, DescriptionFacts};
use crate::project::{ExternalResolution, FileScope, PackageTopics, PackageUsage};
use crate::rindex::provider::CompositeProvider;
use crate::semantic::{FileControlFlow, PackageOrigin, SemanticModel, SymbolProvider};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
use super::diagnostic::{Diagnostic, Severity};
use super::suppression::{DirectiveUsage, SuppressionMap};
pub mod correctness;
pub mod documentation;
pub mod matchers;
pub mod meta;
pub mod packaging;
pub mod performance;
pub mod readability;
pub mod regex;
pub mod roxygen;
pub mod suspicious;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleCategory {
Correctness,
Suspicious,
Readability,
Performance,
Documentation,
Packaging,
Meta,
}
impl RuleCategory {
pub fn title(self) -> &'static str {
match self {
Self::Correctness => "Correctness",
Self::Suspicious => "Suspicious",
Self::Readability => "Readability",
Self::Performance => "Performance",
Self::Documentation => "Documentation",
Self::Packaging => "Packaging",
Self::Meta => "Meta",
}
}
}
pub fn rules_by_category() -> Vec<(RuleCategory, Vec<AnyRule>)> {
vec![
(RuleCategory::Correctness, r_rules(correctness_rules())),
(RuleCategory::Suspicious, r_rules(suspicious_rules())),
(RuleCategory::Readability, r_rules(readability_rules())),
(RuleCategory::Performance, r_rules(performance_rules())),
(RuleCategory::Documentation, r_rules(documentation_rules())),
(RuleCategory::Packaging, packaging_rules()),
(RuleCategory::Meta, r_rules(meta_rules())),
]
}
fn r_rules(rules: Vec<Box<dyn Rule>>) -> Vec<AnyRule> {
rules.into_iter().map(AnyRule::R).collect()
}
pub fn all_rules() -> Vec<AnyRule> {
rules_by_category()
.into_iter()
.flat_map(|(_, rules)| rules)
.collect()
}
fn correctness_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(correctness::UndefinedSymbol),
Box::new(correctness::UnusedBinding),
Box::new(correctness::DuplicateFormal),
Box::new(correctness::DuplicatedArguments),
Box::new(correctness::EqualsNa),
Box::new(correctness::VectorLogic),
Box::new(correctness::UnreachableCode),
Box::new(correctness::IsNumeric),
Box::new(correctness::IfAlwaysTrue),
Box::new(correctness::EmptyAssignment),
Box::new(correctness::DownloadFile),
Box::new(correctness::InternalFunction),
Box::new(correctness::RCompat),
]
}
fn suspicious_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(suspicious::AssignmentInCondition),
Box::new(suspicious::ImplicitAssignment),
Box::new(suspicious::Browser),
Box::new(suspicious::ShadowedBuiltin),
Box::new(suspicious::RedundantEquals),
Box::new(suspicious::RedundantIfelse),
Box::new(suspicious::Repeat),
Box::new(suspicious::UndesirableFunction),
Box::new(suspicious::ForLoopIndex),
Box::new(suspicious::ForLoopDupIndex),
Box::new(suspicious::UnusedFunction),
Box::new(suspicious::DuplicatedFunctionDefinition),
]
}
fn readability_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(readability::TrueFalseSymbol),
Box::new(readability::ComparisonNegation),
Box::new(readability::OuterNegation),
Box::new(readability::StringBoundary),
Box::new(readability::UnnecessaryNesting),
]
}
fn performance_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(performance::AnyIsNa),
Box::new(performance::AnyDuplicated),
Box::new(performance::Coalesce),
Box::new(performance::Crossprod),
Box::new(performance::Lengths),
Box::new(performance::Nzchar),
Box::new(performance::Seq),
Box::new(performance::ClassEquals),
Box::new(performance::FixedRegex),
Box::new(performance::Sort),
]
}
fn documentation_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(documentation::RoxygenUnknownTag),
Box::new(documentation::RoxygenTitle),
Box::new(documentation::RoxygenReturn),
Box::new(documentation::RoxygenParam),
Box::new(documentation::RoxygenExamples),
Box::new(documentation::Roxygen2Compat),
]
}
fn packaging_rules() -> Vec<AnyRule> {
vec![
AnyRule::R(Box::new(packaging::UndeclaredDependency)),
AnyRule::Dcf(Box::new(packaging::DescriptionMissingField)),
AnyRule::Dcf(Box::new(packaging::DescriptionDuplicateField)),
AnyRule::Dcf(Box::new(packaging::DescriptionVersionConstraint)),
AnyRule::Dcf(Box::new(packaging::DescriptionPackageInMultipleFields)),
AnyRule::Dcf(Box::new(packaging::DescriptionMalformedName)),
AnyRule::Dcf(Box::new(packaging::DescriptionMalformedVersion)),
AnyRule::Dcf(Box::new(packaging::DescriptionMalformedMaintainer)),
AnyRule::Dcf(Box::new(packaging::DescriptionAuthorsAtR)),
AnyRule::Dcf(Box::new(packaging::DescriptionEmptyPerson)),
AnyRule::Dcf(Box::new(packaging::UnusedDependency)),
]
}
fn meta_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(meta::MisnamedSuppression),
Box::new(meta::BlanketSuppression),
Box::new(meta::MisplacedSuppression),
Box::new(meta::DeprecatedSuppression),
Box::new(meta::UnexplainedSuppression),
Box::new(meta::OutdatedSuppression),
]
}
pub fn all_rule_ids() -> Vec<&'static str> {
all_rules().iter().map(|r| r.id()).collect()
}
pub fn is_known_rule(id: &str) -> bool {
static IDS: OnceLock<HashSet<&'static str>> = OnceLock::new();
IDS.get_or_init(|| all_rule_ids().into_iter().collect())
.contains(id)
}
pub struct Example {
pub caption: &'static str,
pub source: &'static str,
}
pub trait Rule: Send + Sync {
fn id(&self) -> &'static str;
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn default_enabled(&self) -> bool {
true
}
fn description(&self) -> &'static str {
""
}
fn examples(&self) -> &'static [Example] {
&[]
}
fn interests(&self) -> &'static [SyntaxKind] {
&[]
}
fn check(&self, el: &SyntaxElement, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (el, ctx, sink);
}
fn check_file(&self, ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (ctx, sink);
}
fn check_suppressions(
&self,
ctx: &RuleContext<'_>,
used: &DirectiveUsage,
sink: &mut Vec<Diagnostic>,
) {
let _ = (ctx, used, sink);
}
fn doc_select(&self) -> &'static [&'static str] {
&[]
}
fn doc_compat(&self) -> CompatConfig {
CompatConfig::default()
}
fn doc_package(&self) -> &'static [(&'static str, &'static str)] {
&[]
}
}
pub trait DcfRule: Send + Sync {
fn id(&self) -> &'static str;
fn default_severity(&self) -> Severity {
Severity::Warning
}
fn default_enabled(&self) -> bool {
true
}
fn description(&self) -> &'static str {
""
}
fn examples(&self) -> &'static [Example] {
&[]
}
fn doc_select(&self) -> &'static [&'static str] {
&[]
}
fn doc_package(&self) -> &'static [(&'static str, &'static str)] {
&[]
}
fn interests(&self) -> &'static [dcf::SyntaxKind] {
&[]
}
fn check(&self, el: &dcf::SyntaxElement, ctx: &DcfRuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (el, ctx, sink);
}
fn check_file(&self, ctx: &DcfRuleContext<'_>, sink: &mut Vec<Diagnostic>) {
let _ = (ctx, sink);
}
}
pub enum AnyRule {
R(Box<dyn Rule>),
Dcf(Box<dyn DcfRule>),
}
impl AnyRule {
pub fn id(&self) -> &'static str {
match self {
Self::R(rule) => rule.id(),
Self::Dcf(rule) => rule.id(),
}
}
pub fn default_severity(&self) -> Severity {
match self {
Self::R(rule) => rule.default_severity(),
Self::Dcf(rule) => rule.default_severity(),
}
}
pub fn default_enabled(&self) -> bool {
match self {
Self::R(rule) => rule.default_enabled(),
Self::Dcf(rule) => rule.default_enabled(),
}
}
pub fn description(&self) -> &'static str {
match self {
Self::R(rule) => rule.description(),
Self::Dcf(rule) => rule.description(),
}
}
pub fn examples(&self) -> &'static [Example] {
match self {
Self::R(rule) => rule.examples(),
Self::Dcf(rule) => rule.examples(),
}
}
pub fn doc_select(&self) -> &'static [&'static str] {
match self {
Self::R(rule) => rule.doc_select(),
Self::Dcf(rule) => rule.doc_select(),
}
}
pub fn doc_compat(&self) -> CompatConfig {
match self {
Self::R(rule) => rule.doc_compat(),
Self::Dcf(_) => CompatConfig::default(),
}
}
pub fn doc_package(&self) -> &'static [(&'static str, &'static str)] {
match self {
Self::R(rule) => rule.doc_package(),
Self::Dcf(rule) => rule.doc_package(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EnabledRules(Vec<&'static str>);
impl EnabledRules {
pub fn contains(&self, id: &str) -> bool {
self.0.contains(&id)
}
}
#[derive(Default)]
pub struct FileContext<'a> {
pub project: Option<&'a FileScope<'a>>,
pub resolution: Option<&'a ExternalResolution>,
pub package: Option<&'a DescriptionFacts>,
pub topics: Option<&'a PackageTopics>,
}
pub struct RuleContext<'a> {
pub path: &'a Path,
pub root: &'a SyntaxNode,
pub model: &'a SemanticModel,
pub cfg: &'a FileControlFlow,
pub symbols: &'a dyn SymbolProvider,
pub project: Option<&'a FileScope<'a>>,
pub resolution: Option<&'a ExternalResolution>,
pub package: Option<&'a DescriptionFacts>,
pub topics: Option<&'a PackageTopics>,
pub config: &'a RulesConfig,
pub suppressions: &'a SuppressionMap,
pub enabled_rules: &'a EnabledRules,
own_package: OnceLock<Option<String>>,
pub compat: &'a CompatConfig,
description_compat: OnceLock<DescriptionCompat>,
roxygen_topics: OnceLock<RoxygenTopics>,
}
impl RuleContext<'_> {
pub(crate) fn roxygen_topics(&self) -> &RoxygenTopics {
self.roxygen_topics
.get_or_init(|| RoxygenTopics::build(self.root))
}
pub fn own_package(&self) -> Option<&str> {
if let Some(facts) = self.package {
return facts.package.as_deref();
}
self.own_package
.get_or_init(|| crate::project::description::package_name_for_file(self.path))
.as_deref()
}
pub fn is_package_r_source(&self) -> bool {
self.package.is_some()
&& self
.path
.parent()
.and_then(Path::file_name)
.is_some_and(|dir| dir == "R")
}
pub fn r_compat_floor(&self) -> Option<CompatVersion> {
self.compat
.r_version()
.or_else(|| self.description_compat().r.clone())
}
pub fn roxygen2_compat_floor(&self) -> Option<CompatVersion> {
self.compat
.roxygen2_version()
.or_else(|| self.description_compat().roxygen2.clone())
}
fn description_compat(&self) -> &DescriptionCompat {
if let Some(facts) = self.package {
return &facts.compat;
}
self.description_compat
.get_or_init(|| crate::project::description::description_compat_for_file(self.path))
}
pub fn resolves_to_base(&self, call: &CallExpr) -> bool {
let Some(name) = matchers::callee_name(call) else {
return false;
};
if !self.symbols.is_base(&name) {
return false;
}
if is_namespace_qualified(call) {
return false;
}
if let Some(callee) = call.callee_token()
&& self.is_locally_shadowed(callee.text_range())
{
return false;
}
origin_is_default(self.symbols.origin(&name, self.model.loaded_packages()))
}
pub fn is_locally_shadowed(&self, range: rowan::TextRange) -> bool {
self.model
.idents()
.iter()
.any(|i| i.range == range && self.model.resolve_local(i).is_some())
}
pub fn read_resolves_to_base(&self, token: &SyntaxToken) -> bool {
let name = token.text();
if !self.symbols.is_base(name) {
return false;
}
if self.is_locally_shadowed(token.text_range()) {
return false;
}
origin_is_default(self.symbols.origin(name, self.model.loaded_packages()))
}
}
fn is_namespace_qualified(call: &CallExpr) -> bool {
let Some(callee) = call.callee_token() else {
return false;
};
callee
.parent()
.and_then(BinaryExpr::cast)
.and_then(|bin| bin.namespace_access())
.is_some_and(|ns| ns.name_token.text_range() == callee.text_range())
}
fn origin_is_default(origin: PackageOrigin) -> bool {
let pkg = match &origin {
PackageOrigin::Resolved(pkg) => Some(pkg.as_str()),
PackageOrigin::Ambiguous(pkgs) => pkgs.last().map(|p| p.as_str()),
PackageOrigin::Unknown => None,
};
pkg.is_some_and(|p| crate::semantic::symbols::default_packages().contains(&p))
}
pub struct ResolvedRules {
pub rules: Vec<Box<dyn Rule>>,
by_kind: Vec<Vec<usize>>,
any_node_rules: bool,
dcf_rules: Vec<Box<dyn DcfRule>>,
dcf_by_kind: Vec<Vec<usize>>,
dcf_any_node_rules: bool,
severities: HashMap<&'static str, Severity>,
enabled: EnabledRules,
rules_config: RulesConfig,
compat: CompatConfig,
}
impl ResolvedRules {
fn with_config(chosen: Vec<AnyRule>, rules_config: RulesConfig, compat: CompatConfig) -> Self {
let severities = chosen
.iter()
.map(|r| (r.id(), r.default_severity()))
.collect();
let enabled = EnabledRules(chosen.iter().map(|r| r.id()).collect());
let mut rules: Vec<Box<dyn Rule>> = Vec::new();
let mut dcf_rules: Vec<Box<dyn DcfRule>> = Vec::new();
for rule in chosen {
match rule {
AnyRule::R(rule) => rules.push(rule),
AnyRule::Dcf(rule) => dcf_rules.push(rule),
}
}
let mut by_kind: Vec<Vec<usize>> = vec![Vec::new(); SyntaxKind::COUNT];
let mut any_node_rules = false;
for (i, rule) in rules.iter().enumerate() {
for kind in rule.interests() {
by_kind[*kind as usize].push(i);
any_node_rules = true;
}
}
let mut dcf_by_kind: Vec<Vec<usize>> = vec![Vec::new(); dcf::SyntaxKind::COUNT];
let mut dcf_any_node_rules = false;
for (i, rule) in dcf_rules.iter().enumerate() {
for kind in rule.interests() {
dcf_by_kind[*kind as usize].push(i);
dcf_any_node_rules = true;
}
}
Self {
rules,
by_kind,
any_node_rules,
dcf_rules,
dcf_by_kind,
dcf_any_node_rules,
severities,
enabled,
rules_config,
compat,
}
}
pub fn enabled(&self) -> &EnabledRules {
&self.enabled
}
pub fn resolve(config: &LintConfig) -> (Self, Vec<String>) {
let select = config.select.as_deref();
let ignore = &config.ignore;
let all = all_rules();
let mut unknown = Vec::new();
for id in select.iter().flat_map(|v| v.iter()).chain(ignore.iter()) {
if !all.iter().any(|r| r.id() == id.as_str()) {
unknown.push(id.clone());
}
}
let mut chosen: Vec<AnyRule> = match select {
Some(picks) => all
.into_iter()
.filter(|r| picks.iter().any(|p| p == r.id()))
.collect(),
None => all.into_iter().filter(|r| r.default_enabled()).collect(),
};
chosen.retain(|r| !ignore.iter().any(|i| i == r.id()));
(
Self::with_config(chosen, config.rules.clone(), config.compat.clone()),
unknown,
)
}
pub fn default_set() -> Self {
let (set, _) = Self::resolve(&LintConfig::default());
set
}
}
pub fn run_rules(
resolved: &ResolvedRules,
path: &Path,
root: &SyntaxNode,
model: &SemanticModel,
cfg: &FileControlFlow,
symbols: &dyn SymbolProvider,
file: &FileContext<'_>,
) -> Vec<Diagnostic> {
let suppressions = SuppressionMap::build(root);
let ctx = RuleContext {
path,
root,
model,
cfg,
symbols,
project: file.project,
resolution: file.resolution,
package: file.package,
topics: file.topics,
config: &resolved.rules_config,
suppressions: &suppressions,
enabled_rules: &resolved.enabled,
own_package: OnceLock::new(),
compat: &resolved.compat,
description_compat: OnceLock::new(),
roxygen_topics: OnceLock::new(),
};
let rules = &resolved.rules;
let mut all = Vec::new();
if resolved.any_node_rules {
for el in root.descendants_with_tokens() {
for &i in &resolved.by_kind[el.kind() as usize] {
rules[i].check(&el, &ctx, &mut all);
}
}
}
for rule in rules {
rule.check_file(&ctx, &mut all);
}
let used = suppressions.filter(&mut all);
let mut post = Vec::new();
for rule in rules {
rule.check_suppressions(&ctx, &used, &mut post);
}
if !post.is_empty() {
post.retain(|d| !suppressions.is_suppressed(d.rule, d.range));
all.append(&mut post);
}
stamp_and_sort(resolved, &mut all);
all
}
fn stamp_and_sort(resolved: &ResolvedRules, all: &mut [Diagnostic]) {
for d in all.iter_mut() {
if let Some(&sev) = resolved.severities.get(d.rule) {
d.severity = sev;
}
}
all.sort_by(|a, b| {
(u32::from(a.range.start()), u32::from(a.range.end()), a.rule).cmp(&(
u32::from(b.range.start()),
u32::from(b.range.end()),
b.rule,
))
});
}
pub struct DcfRuleContext<'a> {
pub path: &'a Path,
pub root: &'a dcf::SyntaxNode,
pub document: &'a dcf::Document,
pub facts: &'a DescriptionFacts,
pub usage: Option<&'a PackageUsage>,
pub config: &'a RulesConfig,
pub suppressions: &'a SuppressionMap,
pub enabled_rules: &'a EnabledRules,
}
pub fn run_dcf_rules(
resolved: &ResolvedRules,
path: &Path,
root: &dcf::SyntaxNode,
document: &dcf::Document,
facts: &DescriptionFacts,
usage: Option<&PackageUsage>,
) -> Vec<Diagnostic> {
let suppressions = SuppressionMap::build_dcf(root);
let ctx = DcfRuleContext {
path,
root,
document,
facts,
usage,
config: &resolved.rules_config,
suppressions: &suppressions,
enabled_rules: &resolved.enabled,
};
let rules = &resolved.dcf_rules;
let mut all = Vec::new();
if resolved.dcf_any_node_rules {
for el in root.descendants_with_tokens() {
for &i in &resolved.dcf_by_kind[el.kind() as usize] {
rules[i].check(&el, &ctx, &mut all);
}
}
}
for rule in rules {
rule.check_file(&ctx, &mut all);
}
suppressions.filter(&mut all);
stamp_and_sort(resolved, &mut all);
all
}
pub fn default_symbol_provider() -> CompositeProvider {
CompositeProvider::base_only()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::diagnostic::ViolationData;
struct FakeError;
impl Rule for FakeError {
fn id(&self) -> &'static str {
"fake-error"
}
fn default_severity(&self) -> Severity {
Severity::Error
}
fn interests(&self) -> &'static [SyntaxKind] {
&[SyntaxKind::CALL_EXPR]
}
fn check(&self, el: &SyntaxElement, _ctx: &RuleContext<'_>, sink: &mut Vec<Diagnostic>) {
sink.push(Diagnostic {
rule: "fake-error",
severity: Default::default(),
path: Default::default(),
range: el.text_range(),
message: ViolationData::new("fake-error", "boom"),
fix: None,
});
}
}
struct FakeDcfError;
impl DcfRule for FakeDcfError {
fn id(&self) -> &'static str {
"fake-dcf-error"
}
fn default_severity(&self) -> Severity {
Severity::Error
}
fn interests(&self) -> &'static [dcf::SyntaxKind] {
&[dcf::SyntaxKind::FIELD]
}
fn check(
&self,
el: &dcf::SyntaxElement,
_ctx: &DcfRuleContext<'_>,
sink: &mut Vec<Diagnostic>,
) {
sink.push(Diagnostic {
rule: "fake-dcf-error",
severity: Default::default(),
path: Default::default(),
range: el.text_range(),
message: ViolationData::new("fake-dcf-error", "boom"),
fix: None,
});
}
}
fn dcf_resolved() -> ResolvedRules {
ResolvedRules::with_config(
vec![AnyRule::Dcf(Box::new(FakeDcfError))],
RulesConfig::default(),
CompatConfig::default(),
)
}
fn run_dcf(resolved: &ResolvedRules, text: &str) -> Vec<Diagnostic> {
let parsed = crate::dcf::parse(text);
let document = parsed.document();
let facts = DescriptionFacts::from_document(&document);
run_dcf_rules(
resolved,
Path::new("DESCRIPTION"),
&parsed.cst,
&document,
&facts,
None,
)
}
#[test]
fn with_config_files_rules_by_grammar() {
let resolved = ResolvedRules::with_config(
vec![
AnyRule::R(Box::new(FakeError)),
AnyRule::Dcf(Box::new(FakeDcfError)),
],
RulesConfig::default(),
CompatConfig::default(),
);
assert_eq!(resolved.rules.len(), 1);
assert_eq!(resolved.dcf_rules.len(), 1);
assert_eq!(resolved.by_kind[SyntaxKind::CALL_EXPR as usize], vec![0]);
assert_eq!(
resolved.dcf_by_kind[dcf::SyntaxKind::FIELD as usize],
vec![0]
);
assert!(resolved.enabled().contains("fake-error"));
assert!(resolved.enabled().contains("fake-dcf-error"));
}
#[test]
fn run_dcf_rules_stamps_default_severity() {
let diags = run_dcf(&dcf_resolved(), "Package: p\n");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
}
#[test]
fn run_dcf_rules_filters_suppressed_findings() {
let diags = run_dcf(
&dcf_resolved(),
"# arity-ignore fake-dcf-error: quiet\nPackage: p\n",
);
assert!(diags.is_empty(), "expected no findings, got {diags:?}");
}
fn field_range(text: &str, name: &str) -> rowan::TextRange {
crate::dcf::parse(text)
.document()
.field(name)
.unwrap_or_else(|| panic!("a `{name}` field"))
.syntax()
.text_range()
}
#[test]
fn dcf_node_directive_covers_only_the_next_field() {
let text = "# arity-ignore fake-dcf-error: quiet\nPackage: p\nVersion: 1.0\n";
let diags = run_dcf(&dcf_resolved(), text);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].range, field_range(text, "Version"));
}
#[test]
fn dcf_trailing_directive_covers_the_following_field() {
let text = "Package: p\n# arity-ignore fake-dcf-error: quiet\nVersion: 1.0\n";
let diags = run_dcf(&dcf_resolved(), text);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].range, field_range(text, "Package"));
}
#[test]
fn dcf_directive_inside_a_field_covers_that_field() {
let diags = run_dcf(
&dcf_resolved(),
"Imports:\n dplyr,\n# arity-ignore fake-dcf-error: quiet\n rlang\n",
);
assert!(diags.is_empty(), "expected no findings, got {diags:?}");
}
#[test]
fn dcf_file_directive_covers_the_whole_document() {
let diags = run_dcf(
&dcf_resolved(),
"# arity-ignore-file: quiet\nPackage: p\nVersion: 1.0\n",
);
assert!(diags.is_empty(), "expected no findings, got {diags:?}");
}
#[test]
fn dcf_dangling_directive_suppresses_nothing() {
let text = "Package: p\n\n# arity-ignore fake-dcf-error: quiet\n";
let map = SuppressionMap::build_dcf(&crate::dcf::parse(text).cst);
assert_eq!(map.directives().len(), 1);
assert!(map.directives()[0].is_dangling());
assert_eq!(run_dcf(&dcf_resolved(), text).len(), 1);
}
#[test]
fn run_rules_stamps_default_severity() {
let root = crate::parser::parse("f(1)").cst;
let model = SemanticModel::build(&root);
let cfg = FileControlFlow::build(&root);
let symbols = crate::semantic::StaticBaseR::new();
let resolved = ResolvedRules::with_config(
vec![AnyRule::R(Box::new(FakeError))],
RulesConfig::default(),
CompatConfig::default(),
);
let diags = run_rules(
&resolved,
Path::new("test.R"),
&root,
&model,
&cfg,
&symbols,
&FileContext::default(),
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].severity, Severity::Error);
}
#[test]
fn run_rules_filters_suppressed_findings() {
let root = crate::parser::parse("# arity-ignore fake-error: quiet\nf(1)\n").cst;
let model = SemanticModel::build(&root);
let cfg = FileControlFlow::build(&root);
let symbols = crate::semantic::StaticBaseR::new();
let resolved = ResolvedRules::with_config(
vec![AnyRule::R(Box::new(FakeError))],
RulesConfig::default(),
CompatConfig::default(),
);
let diags = run_rules(
&resolved,
Path::new("test.R"),
&root,
&model,
&cfg,
&symbols,
&FileContext::default(),
);
assert!(diags.is_empty(), "expected no findings, got {diags:?}");
}
#[test]
fn enabled_rules_reflects_the_resolved_set() {
let resolved = ResolvedRules::with_config(
vec![AnyRule::R(Box::new(FakeError))],
RulesConfig::default(),
CompatConfig::default(),
);
assert!(resolved.enabled().contains("fake-error"));
assert!(!resolved.enabled().contains("unused-binding"));
}
fn resolves(src: &str) -> bool {
let root = crate::parser::parse(src).cst;
let model = SemanticModel::build(&root);
let cfg = FileControlFlow::build(&root);
let symbols = crate::semantic::StaticBaseR::new();
let ctx = RuleContext {
path: Path::new("test.R"),
root: &root,
model: &model,
cfg: &cfg,
symbols: &symbols,
project: None,
resolution: None,
package: None,
topics: None,
config: &RulesConfig::default(),
suppressions: &SuppressionMap::default(),
enabled_rules: &EnabledRules::default(),
own_package: OnceLock::new(),
compat: &CompatConfig::default(),
description_compat: OnceLock::new(),
roxygen_topics: OnceLock::new(),
};
let call = root
.descendants()
.find_map(CallExpr::cast)
.expect("a call in the source");
ctx.resolves_to_base(&call)
}
#[test]
fn confirms_unshadowed_base_call() {
assert!(resolves("c(1, 2)"));
assert!(resolves("f <- function() sum(a)"));
}
#[test]
fn rejects_local_value_shadow() {
assert!(!resolves("c <- 1\nc(2, 3)"));
}
#[test]
fn rejects_function_redefinition() {
assert!(!resolves("any <- function(x) x\nany(z)"));
}
#[test]
fn rejects_nested_scope_shadow() {
assert!(!resolves("f <- function() {\n sum <- 1\n sum(a)\n}"));
}
#[test]
fn rejects_non_base_name() {
assert!(!resolves("frobnicate(1)"));
}
#[test]
fn rejects_qualified_callee() {
assert!(!resolves("dplyr::filter(x)"));
}
#[test]
fn rejects_computed_callee() {
assert!(!resolves("(g())(1)"));
}
}