use std::collections::BTreeSet;
use std::sync::LazyLock;
use strum::{EnumIter, IntoEnumIterator as _, IntoStaticStr};
use crate::code_lint::contract::RuleTarget;
use crate::code_lint::rules::CODE_RULES;
use crate::code_lint::suppression::SUPPRESSION_AUDITS;
use crate::command_lint::rules::COMMAND_RULES;
use crate::diagnostic::{Language, RuleName, ViolationTemplate};
use crate::rule_declaration::{
Classification, Consensus, DeclaredOptions, DeclaredRule, ImpactedQuality, Precision, RuleDoc,
Topic, closest_match,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, EnumIter, IntoStaticStr)]
#[strum(serialize_all = "kebab-case")]
pub enum Derived {
Python,
Rust,
Code,
Command,
TestsOnly,
SourceOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter)]
pub enum Facet {
Topic,
Precision,
Consensus,
ImpactedQuality,
Languages,
Input,
FileScope,
}
impl Facet {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Topic => "Topic",
Self::Precision => "Precision",
Self::Consensus => "Consensus",
Self::ImpactedQuality => "Impacted quality",
Self::Languages => "Languages",
Self::Input => "Analyzed input",
Self::FileScope => "File scope",
}
}
#[must_use]
pub fn matches_label(self, candidate: &str) -> bool {
let normalize = |byte: u8| match byte {
b' ' | b'_' => b'-',
other => other.to_ascii_lowercase(),
};
let label = self.label().as_bytes();
let candidate = candidate.as_bytes();
label.len() == candidate.len()
&& label
.iter()
.zip(candidate)
.all(|(&left, &right)| normalize(left) == normalize(right))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Tag {
Topic(Topic),
Precision(Precision),
Consensus(Consensus),
ImpactedQuality(ImpactedQuality),
Derived(Derived),
}
impl Tag {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Topic(value) => value.label,
Self::Precision(value) => value.into(),
Self::Consensus(value) => value.into(),
Self::ImpactedQuality(value) => value.into(),
Self::Derived(value) => value.into(),
}
}
#[must_use]
pub const fn facet(self) -> Facet {
match self {
Self::Topic(_) => Facet::Topic,
Self::Precision(_) => Facet::Precision,
Self::Consensus(_) => Facet::Consensus,
Self::ImpactedQuality(_) => Facet::ImpactedQuality,
Self::Derived(Derived::Python | Derived::Rust) => Facet::Languages,
Self::Derived(Derived::Code | Derived::Command) => Facet::Input,
Self::Derived(Derived::TestsOnly | Derived::SourceOnly) => Facet::FileScope,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Selector {
Rule(RuleName),
Tag(Tag),
}
impl Selector {
pub fn label(self) -> &'static str {
match self {
Self::Rule(rule) => rule.0,
Self::Tag(tag) => tag.label(),
}
}
}
#[derive(Debug)]
pub struct RegisteredRule {
pub name: RuleName,
pub doc: RuleDoc,
pub template: &'static ViolationTemplate,
pub options: DeclaredOptions,
pub languages: &'static [Language],
classification: Classification,
derived: Vec<Derived>,
}
impl RegisteredRule {
fn new(declared: DeclaredRule, registry: impl IntoIterator<Item = Derived>) -> Self {
let languages = declared.languages.iter().map(|language| match language {
Language::Python => Derived::Python,
Language::Rust => Derived::Rust,
});
Self {
name: declared.name,
doc: declared.doc,
template: declared.template,
options: declared.options,
languages: declared.languages,
classification: declared.classification,
derived: languages.chain(registry).collect(),
}
}
}
pub static REGISTERED_RULES: LazyLock<Vec<RegisteredRule>> = LazyLock::new(|| {
let code = CODE_RULES.iter().map(|rule| {
let scope = match rule.target() {
RuleTarget::All => None,
RuleTarget::TestsOnly => Some(Derived::TestsOnly),
RuleTarget::SourceOnly => Some(Derived::SourceOnly),
};
RegisteredRule::new(
rule.declaration(),
std::iter::once(Derived::Code).chain(scope),
)
});
let audits = SUPPRESSION_AUDITS
.iter()
.map(|audit| RegisteredRule::new(audit.declared(), [Derived::Code]));
let commands = COMMAND_RULES
.iter()
.map(|rule| RegisteredRule::new(rule.declaration.declared(), [Derived::Command]));
code.chain(audits).chain(commands).collect()
});
impl Topic {
fn path(self) -> Vec<Self> {
let mut path: Vec<_> = self.ancestors().collect();
path.reverse();
path.push(self);
path
}
fn ancestors(self) -> impl Iterator<Item = Self> {
std::iter::successors(self.parent.copied(), |topic| topic.parent.copied())
}
}
pub fn all_tags() -> impl Iterator<Item = Tag> {
all_topics()
.map(Tag::Topic)
.chain(Precision::iter().map(Tag::Precision))
.chain(Consensus::iter().map(Tag::Consensus))
.chain(ImpactedQuality::iter().map(Tag::ImpactedQuality))
.chain(Derived::iter().map(Tag::Derived))
}
impl RegisteredRule {
pub fn branches(&self) -> Vec<Vec<Tag>> {
let declared = self.classification;
let topic_paths = declared
.topics
.iter()
.map(|topic| topic.path().into_iter().map(Tag::Topic).collect());
let values = [
Tag::Precision(declared.precision),
Tag::Consensus(declared.consensus),
Tag::ImpactedQuality(declared.impacted_quality),
]
.into_iter()
.chain(self.derived.iter().copied().map(Tag::Derived))
.map(|tag| vec![tag]);
topic_paths.chain(values).collect()
}
#[must_use]
pub fn matches(&self, selector: Selector) -> bool {
match selector {
Selector::Rule(name) => name == self.name,
Selector::Tag(tag) => self.branches().iter().flatten().any(|&own| own == tag),
}
}
#[cfg(test)]
pub(crate) fn synthetic(name: &'static str, classification: Classification) -> Self {
static TEMPLATE: ViolationTemplate = ViolationTemplate::from_static("", "", "");
Self {
name: RuleName(name),
doc: RuleDoc::TODO,
template: &TEMPLATE,
options: crate::rule_declaration::RuleOptions::none().declared(),
languages: &[],
classification,
derived: Vec::new(),
}
}
}
pub fn lookup(label: &str) -> Option<Selector> {
labels().find_map(|(known, selector)| (known == label).then_some(selector))
}
pub fn closest_label(label: &str) -> Option<&'static str> {
closest_match(label, labels().map(|(known, _)| known))
}
pub fn closest_rule_name(label: &str) -> Option<&'static str> {
closest_match(label, REGISTERED_RULES.iter().map(|rule| rule.name.0))
}
#[must_use]
pub fn registered_topics() -> Vec<Topic> {
let mut topics: Vec<Topic> = all_topics().collect();
topics.sort_by_cached_key(|topic| {
topic
.path()
.into_iter()
.map(|step| step.label)
.collect::<Vec<_>>()
});
topics
}
fn labels() -> impl Iterator<Item = (&'static str, Selector)> {
let rules = REGISTERED_RULES
.iter()
.map(|rule| (rule.name.0, Selector::Rule(rule.name)));
let tags = all_tags().map(|tag| (tag.label(), Selector::Tag(tag)));
let synonyms = all_topics().flat_map(|topic| {
let selector = Selector::Tag(Tag::Topic(topic));
topic
.synonyms
.iter()
.map(move |&synonym| (synonym, selector))
});
rules.chain(tags).chain(synonyms)
}
fn all_topics() -> impl Iterator<Item = Topic> {
let topics: BTreeSet<Topic> = REGISTERED_RULES
.iter()
.flat_map(|rule| rule.classification.topics.iter().copied())
.flat_map(Topic::path)
.collect();
topics.into_iter()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::rule_declaration::is_kebab_case;
#[test]
fn every_rule_has_a_topic() {
let untopical: Vec<_> = REGISTERED_RULES
.iter()
.filter(|rule| rule.classification.topics.is_empty())
.map(|rule| rule.name.0)
.collect();
assert!(
untopical.is_empty(),
"declare at least one topic for {untopical:?}"
);
}
#[test]
fn no_rule_lists_a_topic_with_its_ancestor_or_twice() {
let mut failures = Vec::new();
for rule in REGISTERED_RULES.iter() {
let topics = rule.classification.topics;
for (index, &topic) in topics.iter().enumerate() {
if topics[..index].contains(&topic) {
failures.push(format!("{}: `{}` is listed twice", rule.name, topic.label));
}
if let Some(ancestor) = topic.ancestors().find(|ancestor| topics.contains(ancestor))
{
failures.push(format!(
"{}: remove `{}`, it is implied by `{}`",
rule.name, ancestor.label, topic.label
));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn every_tag_has_a_rule() {
let empty: Vec<_> = all_tags()
.filter(|&tag| {
!REGISTERED_RULES
.iter()
.any(|rule| rule.matches(Selector::Tag(tag)))
})
.map(Tag::label)
.collect();
assert!(empty.is_empty(), "no rule carries {empty:?}: remove them");
}
#[test]
fn every_topic_is_documented() {
let undocumented: Vec<_> = all_topics()
.filter(|topic| {
topic.description.trim().is_empty() || topic.scope_note.trim().is_empty()
})
.map(|topic| topic.label)
.collect();
assert!(undocumented.is_empty(), "document {undocumented:?}");
}
#[test]
fn labels_are_globally_unique_and_not_facet_labels() {
let mut owners: BTreeMap<&str, Vec<Selector>> = BTreeMap::new();
for (label, selector) in labels() {
owners.entry(label).or_default().push(selector);
}
let collisions: Vec<_> = owners
.iter()
.filter(|(_, selectors)| selectors.len() > 1)
.map(|(label, selectors)| format!("`{label}` names {selectors:?}"))
.collect();
let non_kebab: Vec<_> = owners
.keys()
.filter(|label| !is_kebab_case(label))
.collect();
let facet_like: Vec<_> = owners
.keys()
.filter(|label| Facet::iter().any(|facet| facet.matches_label(label)))
.collect();
assert!(collisions.is_empty(), "{}", collisions.join("\n"));
assert!(non_kebab.is_empty(), "{non_kebab:?} are not kebab-case");
assert!(facet_like.is_empty(), "{facet_like:?} read as facet labels");
}
#[test]
fn code_rules_and_only_code_rules_have_a_language() {
for rule in REGISTERED_RULES.iter() {
let has_language = rule
.derived
.iter()
.any(|&value| Tag::Derived(value).facet() == Facet::Languages);
assert_eq!(
has_language,
rule.derived.contains(&Derived::Code),
"{}",
rule.name
);
}
}
#[test]
fn a_topic_branch_is_its_path_from_the_root() {
const PARENT: Topic = Topic {
label: "parent",
parent: None,
description: "",
scope_note: "",
synonyms: &[],
};
const CHILD: Topic = Topic {
label: "child",
parent: Some(&PARENT),
..PARENT
};
let rule = RegisteredRule::synthetic(
"rule",
Classification {
topics: &[CHILD],
precision: Precision::Exact,
consensus: Consensus::Unopinionated,
impacted_quality: ImpactedQuality::Reliability,
},
);
assert_eq!(
rule.branches(),
[
vec![Tag::Topic(PARENT), Tag::Topic(CHILD)],
vec![Tag::Precision(Precision::Exact)],
vec![Tag::Consensus(Consensus::Unopinionated)],
vec![Tag::ImpactedQuality(ImpactedQuality::Reliability)],
]
);
}
}