use std::collections::BTreeSet;
use crate::condition::{rule_enabled, ConditionTag};
use crate::config::Profile;
use crate::parser::Document;
use crate::types::{Diagnostic, Language};
pub mod enumeration;
pub mod lexicon;
pub mod readability;
pub mod rhythm;
pub mod structure;
pub mod syntax;
pub use lexicon::all_caps_shouting::AllCapsShouting;
pub use lexicon::consonant_cluster::ConsonantCluster;
pub use lexicon::excessive_nominalization::ExcessiveNominalization;
pub use lexicon::jargon_undefined::JargonUndefined;
pub use lexicon::low_lexical_diversity::LowLexicalDiversity;
pub use lexicon::redundant_intensifier::RedundantIntensifier;
pub use lexicon::unexplained_abbreviation::UnexplainedAbbreviation;
pub use lexicon::weasel_words::WeaselWords;
pub use readability::score::ReadabilityScore;
pub use rhythm::consecutive_long_sentences::ConsecutiveLongSentences;
pub use rhythm::repetitive_connectors::RepetitiveConnectors;
pub use structure::deep_subordination::DeepSubordination;
pub use structure::deeply_nested_lists::DeeplyNestedLists;
pub use structure::excessive_commas::ExcessiveCommas;
pub use structure::heading_jump::HeadingJump;
pub use structure::italic_span_long::ItalicSpanLong;
pub use structure::line_length_wide::LineLengthWide;
pub use structure::long_enumeration::LongEnumeration;
pub use structure::mixed_numeric_format::MixedNumericFormat;
pub use structure::paragraph_too_long::ParagraphTooLong;
pub use structure::sentence_too_long::SentenceTooLong;
pub use syntax::conditional_stacking::ConditionalStacking;
pub use syntax::dense_punctuation_burst::DensePunctuationBurst;
pub use syntax::nested_negation::NestedNegation;
pub use syntax::passive_voice::PassiveVoice;
pub use syntax::unclear_antecedent::UnclearAntecedent;
pub trait Rule {
fn id(&self) -> &'static str;
fn check(&self, document: &Document, language: Language) -> Vec<Diagnostic>;
fn condition_tags(&self) -> &'static [ConditionTag] {
&[ConditionTag::General]
}
fn status(&self) -> Status {
Status::Stable
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Status {
Stable,
Experimental,
}
#[derive(Debug, Clone, Default)]
pub enum ExperimentalOptIn {
#[default]
None,
All,
Ids(BTreeSet<String>),
}
impl ExperimentalOptIn {
pub fn from_selectors<I, S>(selectors: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut ids = BTreeSet::new();
for sel in selectors {
let s: String = sel.into();
if s == "*" {
return Self::All;
}
ids.insert(s);
}
if ids.is_empty() {
Self::None
} else {
Self::Ids(ids)
}
}
#[must_use]
pub fn covers(&self, rule_id: &str) -> bool {
match self {
Self::None => false,
Self::All => true,
Self::Ids(ids) => ids.contains(rule_id),
}
}
}
#[must_use]
pub fn filter_by_conditions(
rules: Vec<Box<dyn Rule>>,
active: &[ConditionTag],
) -> Vec<Box<dyn Rule>> {
rules
.into_iter()
.filter(|r| rule_enabled(r.condition_tags(), active))
.collect()
}
#[must_use]
pub fn experimental_rule_ids() -> std::collections::BTreeSet<&'static str> {
default_rules(Profile::DEFAULT)
.iter()
.filter(|r| r.status() == Status::Experimental)
.map(|r| r.id())
.collect()
}
#[must_use]
pub fn filter_by_experimental(
rules: Vec<Box<dyn Rule>>,
opt_in: &ExperimentalOptIn,
) -> Vec<Box<dyn Rule>> {
rules
.into_iter()
.filter(|r| match r.status() {
Status::Stable => true,
Status::Experimental => opt_in.covers(r.id()),
})
.collect()
}
#[must_use]
pub fn default_rules(profile: Profile) -> Vec<Box<dyn Rule>> {
vec![
Box::new(SentenceTooLong::for_profile(profile)),
Box::new(ParagraphTooLong::for_profile(profile)),
Box::new(HeadingJump::for_profile(profile)),
Box::new(DeeplyNestedLists::for_profile(profile)),
Box::new(ExcessiveCommas::for_profile(profile)),
Box::new(ConsecutiveLongSentences::for_profile(profile)),
Box::new(WeaselWords::for_profile(profile)),
Box::new(UnexplainedAbbreviation::for_profile(profile)),
Box::new(JargonUndefined::for_profile(profile)),
Box::new(ExcessiveNominalization::for_profile(profile)),
Box::new(RepetitiveConnectors::for_profile(profile)),
Box::new(ReadabilityScore::for_profile(profile)),
Box::new(LongEnumeration::for_profile(profile)),
Box::new(DeepSubordination::for_profile(profile)),
Box::new(PassiveVoice::for_profile(profile)),
Box::new(UnclearAntecedent::for_profile(profile)),
Box::new(LowLexicalDiversity::for_profile(profile)),
Box::new(NestedNegation::for_profile(profile)),
Box::new(ConditionalStacking::for_profile(profile)),
Box::new(AllCapsShouting::for_profile(profile)),
Box::new(LineLengthWide::for_profile(profile)),
Box::new(MixedNumericFormat::for_profile(profile)),
Box::new(RedundantIntensifier::for_profile(profile)),
Box::new(DensePunctuationBurst::for_profile(profile)),
Box::new(ConsonantCluster::for_profile(profile)),
Box::new(ItalicSpanLong::for_profile(profile)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_rules_is_non_empty() {
let rules = default_rules(Profile::Public);
assert!(!rules.is_empty());
}
#[test]
fn every_stable_default_rule_is_tagged_general() {
for rule in default_rules(Profile::Public) {
if rule.status() == Status::Experimental {
continue;
}
assert!(
rule.condition_tags().contains(&ConditionTag::General),
"Stable rule `{}` is missing the `general` condition tag (v0.2 baseline)",
rule.id()
);
}
}
#[test]
fn filter_by_conditions_keeps_general_rules() {
let kept = filter_by_conditions(default_rules(Profile::Public), &[]);
assert_eq!(kept.len(), 25);
}
#[test]
fn experimental_cohort_is_tracked() {
let experimental: std::collections::BTreeSet<&str> =
experimental_rule_ids().iter().copied().collect();
let expected = ["structure.italic-span-long"];
for id in &expected {
assert!(
experimental.contains(id),
"expected `{id}` to ship as Experimental in v0.2.x; got {experimental:?}"
);
}
assert_eq!(
experimental.len(),
expected.len(),
"experimental cohort drifted; got {experimental:?}, expected {expected:?}"
);
}
#[test]
fn experimental_opt_in_from_selectors_normalises_star() {
let star = ExperimentalOptIn::from_selectors(["structure.foo", "*"]);
assert!(matches!(star, ExperimentalOptIn::All));
}
#[test]
fn experimental_opt_in_from_empty_iter_is_none() {
let none = ExperimentalOptIn::from_selectors(Vec::<String>::new());
assert!(matches!(none, ExperimentalOptIn::None));
}
#[test]
fn experimental_opt_in_covers_matches_specific_ids() {
let ids = ExperimentalOptIn::from_selectors([
"structure.italic-span-long",
"structure.number-run",
]);
assert!(ids.covers("structure.italic-span-long"));
assert!(!ids.covers("structure.sentence-too-long"));
}
#[test]
fn filter_by_experimental_keeps_all_stable_when_off() {
let kept = filter_by_experimental(default_rules(Profile::Public), &ExperimentalOptIn::None);
assert_eq!(kept.len(), 25);
}
struct FakeExperimental;
impl Rule for FakeExperimental {
fn id(&self) -> &'static str {
"structure.fake-experimental"
}
fn check(&self, _document: &Document, _language: Language) -> Vec<Diagnostic> {
Vec::new()
}
fn status(&self) -> Status {
Status::Experimental
}
}
fn registry_with_fake_experimental() -> Vec<Box<dyn Rule>> {
let mut rules = default_rules(Profile::Public);
rules.push(Box::new(FakeExperimental));
rules
}
#[test]
fn filter_by_experimental_strips_experimental_when_off() {
let kept =
filter_by_experimental(registry_with_fake_experimental(), &ExperimentalOptIn::None);
assert_eq!(
kept.len(),
25,
"experimental rules must be filtered out by default"
);
assert!(kept.iter().all(|r| r.id() != "structure.fake-experimental"));
assert!(kept.iter().all(|r| r.id() != "structure.italic-span-long"));
}
#[test]
fn filter_by_experimental_keeps_experimental_under_wildcard() {
let kept =
filter_by_experimental(registry_with_fake_experimental(), &ExperimentalOptIn::All);
assert_eq!(kept.len(), 27);
assert!(kept.iter().any(|r| r.id() == "structure.fake-experimental"));
assert!(kept.iter().any(|r| r.id() == "structure.italic-span-long"));
}
#[test]
fn filter_by_experimental_keeps_only_opted_in_ids() {
let opt_in = ExperimentalOptIn::from_selectors(["structure.fake-experimental"]);
let kept = filter_by_experimental(registry_with_fake_experimental(), &opt_in);
assert_eq!(kept.len(), 26);
assert!(kept.iter().any(|r| r.id() == "structure.fake-experimental"));
assert!(kept.iter().all(|r| r.id() != "structure.italic-span-long"));
let other = ExperimentalOptIn::from_selectors(["structure.does-not-exist"]);
let kept = filter_by_experimental(registry_with_fake_experimental(), &other);
assert_eq!(kept.len(), 25);
}
#[test]
fn each_rule_has_a_well_formed_id() {
for rule in default_rules(Profile::Public) {
let id = rule.id();
assert!(!id.is_empty(), "empty rule id");
assert!(
id.chars()
.all(|c| c.is_ascii_lowercase() || c == '-' || c == '.'),
"rule id `{id}` contains unexpected characters (only lowercase, `-`, `.` allowed)"
);
let parts: Vec<&str> = id.split('.').collect();
assert_eq!(
parts.len(),
2,
"rule id `{id}` must be `category.rule-name`"
);
assert!(
!parts[0].is_empty() && !parts[1].is_empty(),
"rule id `{id}` has an empty category or name half"
);
}
}
}