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::project::description::DescriptionCompat;
use crate::project::{ExternalResolution, FileScope};
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 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,
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::Meta => "Meta",
}
}
}
pub fn rules_by_category() -> Vec<(RuleCategory, Vec<Box<dyn Rule>>)> {
vec![
(RuleCategory::Correctness, correctness_rules()),
(RuleCategory::Suspicious, suspicious_rules()),
(RuleCategory::Readability, readability_rules()),
(RuleCategory::Performance, performance_rules()),
(RuleCategory::Documentation, documentation_rules()),
(RuleCategory::Meta, meta_rules()),
]
}
pub fn all_rules() -> Vec<Box<dyn Rule>> {
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 meta_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(meta::MisnamedSuppression),
Box::new(meta::BlanketSuppression),
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()
}
}
#[derive(Debug, Clone, Default)]
pub struct EnabledRules(Vec<&'static str>);
impl EnabledRules {
pub fn contains(&self, id: &str) -> bool {
self.0.contains(&id)
}
}
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 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>,
}
impl RuleContext<'_> {
pub fn own_package(&self) -> Option<&str> {
self.own_package
.get_or_init(|| crate::project::description::package_name_for_file(self.path))
.as_deref()
}
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 {
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,
severities: HashMap<&'static str, Severity>,
enabled: EnabledRules,
rules_config: RulesConfig,
compat: CompatConfig,
}
impl ResolvedRules {
fn with_config(
rules: Vec<Box<dyn Rule>>,
rules_config: RulesConfig,
compat: CompatConfig,
) -> Self {
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 severities = rules
.iter()
.map(|r| (r.id(), r.default_severity()))
.collect();
let enabled = EnabledRules(rules.iter().map(|r| r.id()).collect());
Self {
rules,
by_kind,
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<Box<dyn Rule>> = 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
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_rules(
resolved: &ResolvedRules,
path: &Path,
root: &SyntaxNode,
model: &SemanticModel,
cfg: &FileControlFlow,
symbols: &dyn SymbolProvider,
project: Option<&FileScope<'_>>,
resolution: Option<&ExternalResolution>,
) -> Vec<Diagnostic> {
let suppressions = SuppressionMap::build(root);
let ctx = RuleContext {
path,
root,
model,
cfg,
symbols,
project,
resolution,
config: &resolved.rules_config,
suppressions: &suppressions,
enabled_rules: &resolved.enabled,
own_package: OnceLock::new(),
compat: &resolved.compat,
description_compat: 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);
}
for d in &mut all {
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,
))
});
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,
});
}
}
#[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![Box::new(FakeError)],
RulesConfig::default(),
CompatConfig::default(),
);
let diags = run_rules(
&resolved,
Path::new("test.R"),
&root,
&model,
&cfg,
&symbols,
None,
None,
);
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![Box::new(FakeError)],
RulesConfig::default(),
CompatConfig::default(),
);
let diags = run_rules(
&resolved,
Path::new("test.R"),
&root,
&model,
&cfg,
&symbols,
None,
None,
);
assert!(diags.is_empty(), "expected no findings, got {diags:?}");
}
#[test]
fn enabled_rules_reflects_the_resolved_set() {
let resolved = ResolvedRules::with_config(
vec![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,
config: &RulesConfig::default(),
suppressions: &SuppressionMap::default(),
enabled_rules: &EnabledRules::default(),
own_package: OnceLock::new(),
compat: &CompatConfig::default(),
description_compat: 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)"));
}
}