architecture_component!(CodeLintContract);
use crate::code_lint::ast::{AstNode, ParsedFile};
use crate::code_lint::semantic::{bindings, calls, comments::CommentIndex};
use crate::diagnostic::{Diagnostic, Language, RuleName};
use crate::rule_declaration::{
Declaration, DeclaredRule, EnforcementMode, OptionsDeclaration, RuleOverrides,
};
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RuleTarget {
#[default]
All,
TestsOnly,
SourceOnly,
}
#[derive(Clone, Copy)]
pub struct CodeRule<Options: OptionsDeclaration = ()> {
pub declaration: Declaration<Options>,
pub target: RuleTarget,
pub(crate) check: for<'a> fn(&Self, &Path, &ParsedFile, Options::Param<'a>) -> Vec<Diagnostic>,
}
impl<Options: OptionsDeclaration> CodeRule<Options> {
#[must_use]
pub fn check_file(
&self,
path: &Path,
file: &ParsedFile,
overrides: Option<&RuleOverrides>,
) -> Vec<Diagnostic> {
let options = self
.declaration
.options
.options
.resolve(file.lang(), overrides);
let mut diagnostics = (self.check)(self, path, file, Options::as_param(&options));
let mode = self
.declaration
.options
.enforcement_mode(file.lang(), overrides);
if mode == EnforcementMode::RequireExplanation && !diagnostics.is_empty() {
let index = CommentIndex::from_file(file);
diagnostics.retain(|diagnostic| {
!index.has_explanation_for_span(
file,
diagnostic.location.span,
diagnostic.location.line,
)
});
for diagnostic in &mut diagnostics {
diagnostic.message.explanation_hint =
Some(EnforcementMode::EXPLANATION_HINT.to_owned());
}
}
diagnostics
}
#[must_use]
pub(super) fn diagnostic_at_node(
&self,
path: &Path,
node: &AstNode<'_>,
params: &[(&str, &str)],
) -> Diagnostic {
self.declaration.render_diagnostic_for_lang(
node.lang(),
params,
node.to_source_location(path),
)
}
#[must_use]
pub(super) fn check_banned_calls(
&self,
path: &Path,
file: &ParsedFile,
banned: &HashSet<String>,
) -> Vec<Diagnostic> {
self.check_banned_calls_where(path, file, banned, |_| true)
}
#[must_use]
pub(super) fn check_banned_calls_where(
&self,
path: &Path,
file: &ParsedFile,
banned: &HashSet<String>,
predicate: impl FnMut(&calls::CallMatch<'_>) -> bool,
) -> Vec<Diagnostic> {
calls::find_banned_calls(file, banned)
.into_iter()
.filter(predicate)
.map(|matched| {
self.diagnostic_at_node(path, &matched.node, &[("callee", &matched.callee)])
})
.collect()
}
#[must_use]
pub(super) fn check_banned_suffixes(
&self,
path: &Path,
file: &ParsedFile,
banned: &HashSet<String>,
) -> Vec<Diagnostic> {
self.check_banned_suffixes_where(path, file, banned, |_| true)
}
#[must_use]
pub(super) fn check_banned_suffixes_where(
&self,
path: &Path,
file: &ParsedFile,
banned: &HashSet<String>,
predicate: impl FnMut(&bindings::SuffixedBindingMatch<'_>) -> bool,
) -> Vec<Diagnostic> {
bindings::find_suffixed_bindings(file, banned)
.into_iter()
.filter(predicate)
.map(|matched| {
self.diagnostic_at_node(
path,
&matched.node,
&[
("name", &matched.name),
("suffix", &matched.actual_suffix),
("stem", &matched.base_name),
],
)
})
.collect()
}
}
pub trait AnyCodeRule: Send + Sync {
fn declaration(&self) -> DeclaredRule;
fn name(&self) -> RuleName;
fn languages(&self) -> &'static [Language];
fn supports_language(&self, language: Language) -> bool {
self.languages().contains(&language)
}
fn target(&self) -> RuleTarget;
fn check_file(
&self,
path: &Path,
file: &ParsedFile,
overrides: Option<&RuleOverrides>,
) -> Vec<Diagnostic>;
}
impl<Options: OptionsDeclaration + Send + Sync> AnyCodeRule for CodeRule<Options> {
fn declaration(&self) -> DeclaredRule {
self.declaration.declared()
}
fn name(&self) -> RuleName {
self.declaration.name
}
fn languages(&self) -> &'static [Language] {
self.declaration.languages
}
fn target(&self) -> RuleTarget {
self.target
}
fn check_file(
&self,
path: &Path,
file: &ParsedFile,
overrides: Option<&RuleOverrides>,
) -> Vec<Diagnostic> {
Self::check_file(self, path, file, overrides)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::{ViolationTemplate, violation_template};
use crate::rule_declaration::{
Classification, Consensus, ImpactedQuality, LanguageDefaults, Precision, RuleDoc,
RuleOptions, Topic,
};
use rstest::rstest;
const TEMPLATE: ViolationTemplate = violation_template! {
summary: "Function `f` is called.",
rationale: "Calling `f` is a test fixture.",
suggestion: "Remove the call.",
};
fn check_calls_to_f(
rule: &CodeRule,
path: &Path,
file: &ParsedFile,
(): (),
) -> Vec<Diagnostic> {
rule.check_banned_calls(path, file, &HashSet::from(["f".to_owned()]))
}
fn rule_with_mode(mode: Option<EnforcementMode>) -> CodeRule {
CodeRule {
declaration: Declaration {
name: RuleName("call-to-f"),
template: &TEMPLATE,
languages: &[Language::Python],
options: RuleOptions {
enforcement_mode: mode.map(|mode| LanguageDefaults::new(mode, &[])),
options: (),
},
classification: Classification {
topics: &[Topic::STATIC_TYPING],
precision: Precision::Exact,
consensus: Consensus::Opinionated,
impacted_quality: ImpactedQuality::Maintainability,
},
doc: RuleDoc::TODO,
},
target: RuleTarget::All,
check: check_calls_to_f,
}
}
#[rstest]
#[case::require_explanation_hints_kept_findings_and_drops_explained_ones(
Some(EnforcementMode::RequireExplanation),
&[(2, Some(EnforcementMode::EXPLANATION_HINT))]
)]
#[case::ban_adds_no_hint(Some(EnforcementMode::Ban), &[(1, None), (2, None)])]
#[case::rule_without_mode_adds_no_hint(None, &[(1, None), (2, None)])]
fn test_check_file_sets_explanation_hint_by_mode(
#[case] mode: Option<EnforcementMode>,
#[case] expected: &[(usize, Option<&str>)],
) {
let source = indoc::indoc! {"
f() # The first call is explained by this comment.
f()
"};
let mut diagnostics = rule_with_mode(mode).check_file(
Path::new("module.py"),
&ParsedFile::new(source, Language::Python),
None,
);
diagnostics.sort_unstable();
let actual: Vec<(usize, Option<&str>)> = diagnostics
.iter()
.map(|diagnostic| {
(
diagnostic.location.line,
diagnostic.message.explanation_hint.as_deref(),
)
})
.collect();
assert_eq!(actual, expected);
}
}