Skip to main content

mdwright_lint/
rule_set.rs

1//! A set of rules to run against a document.
2//!
3//! `RuleSet` is a registry, not a bit-mask: it owns `Box<dyn
4//! LintRule>` values keyed by name. The CLI builds one from
5//! `RuleSet::stdlib_defaults()` and applies `+rule` / `-rule`
6//! adjustments; library callers add their own rules in any
7//! combination they like (see the crate-level extensibility
8//! example).
9//!
10//! Names must be unique inside a set. `add` returns an error rather
11//! than silently dropping or overriding — duplicate registration is
12//! almost always a bug.
13
14use std::fmt;
15
16use mdwright_document::Document;
17
18use crate::LintOptions;
19use crate::diagnostic::Diagnostic;
20use crate::rule::LintRule;
21use crate::stdlib;
22use crate::suppression::SuppressionMap;
23
24/// An ordered, name-unique collection of [`LintRule`]s.
25#[derive(Default)]
26pub struct RuleSet {
27    rules: Vec<Box<dyn LintRule>>,
28}
29
30impl RuleSet {
31    /// An empty set; add rules with [`Self::add`].
32    #[must_use]
33    pub fn new() -> Self {
34        Self { rules: Vec::new() }
35    }
36
37    /// The stdlib's curated default-on rules. Equivalent to
38    /// [`crate::stdlib::defaults`].
39    #[must_use]
40    pub fn stdlib_defaults() -> Self {
41        stdlib::defaults()
42    }
43
44    /// Every stdlib rule, including the default-off ones.
45    /// Equivalent to [`crate::stdlib::all`].
46    #[must_use]
47    pub fn stdlib_all() -> Self {
48        stdlib::all()
49    }
50
51    /// Insert a rule. Names must be unique within the set.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`DuplicateRuleName`] if a rule with the same
56    /// `name()` is already present.
57    pub fn add(&mut self, rule: Box<dyn LintRule>) -> Result<&mut Self, DuplicateRuleName> {
58        if self.contains(rule.name()) {
59            return Err(DuplicateRuleName {
60                name: rule.name().to_owned(),
61            });
62        }
63        self.rules.push(rule);
64        Ok(self)
65    }
66
67    /// Remove the rule with the given `name`. Returns `true` if a
68    /// rule was removed, `false` if no rule had that name.
69    pub fn remove(&mut self, name: &str) -> bool {
70        let before = self.rules.len();
71        self.rules.retain(|r| r.name() != name);
72        self.rules.len() != before
73    }
74
75    #[must_use]
76    pub fn contains(&self, name: &str) -> bool {
77        self.rules.iter().any(|r| r.name() == name)
78    }
79
80    pub fn iter(&self) -> impl Iterator<Item = &dyn LintRule> {
81        self.rules.iter().map(|b| &**b)
82    }
83
84    /// Look up a rule by its `name`.
85    #[must_use]
86    pub fn by_name(&self, name: &str) -> Option<&dyn LintRule> {
87        self.rules.iter().find(|r| r.name() == name).map(|b| &**b)
88    }
89
90    /// Iterate over the names of every rule in the set.
91    pub fn names(&self) -> impl Iterator<Item = &str> {
92        self.rules.iter().map(|r| r.name())
93    }
94
95    /// Run every rule in the set over `doc`.
96    #[must_use]
97    pub fn check(&self, doc: &Document) -> Vec<Diagnostic> {
98        self.check_with(doc, LintOptions::default())
99    }
100
101    /// Run every rule in the set over `doc` under `opts`.
102    #[must_use]
103    pub fn check_with(&self, doc: &Document, opts: LintOptions) -> Vec<Diagnostic> {
104        let mut out = Vec::new();
105        for rule in self.iter() {
106            let before = out.len();
107            rule.check(doc, &mut out);
108            let name_owned = rule.name().to_owned();
109            let advisory = rule.is_advisory();
110            // A rule that emits diagnostics under several rule codes (e.g.
111            // the `math/mathjax-*` family) sets `d.rule` itself. The
112            // dispatcher only stamps when the rule field is still empty, so
113            // pre-stamped codes survive.
114            for d in out.get_mut(before..).into_iter().flatten() {
115                if d.rule.is_empty() {
116                    d.rule = std::borrow::Cow::Owned(name_owned.clone());
117                }
118                d.advisory = advisory;
119            }
120        }
121
122        if opts.respect_suppressions {
123            let user_names: Vec<String> = self.iter().map(|r| r.name().to_owned()).collect();
124            let mut known: Vec<&str> = stdlib::names().collect();
125            for n in &user_names {
126                let s: &str = n.as_str();
127                if !known.contains(&s) {
128                    known.push(s);
129                }
130            }
131            let (map, unknown) = SuppressionMap::build(doc.source(), doc.line_index(), doc.suppressions(), &known);
132            out.retain(|d| !map.suppresses(&d.rule, &d.span));
133            out.extend(unknown);
134        }
135
136        out.sort_by(|a, b| {
137            a.line
138                .cmp(&b.line)
139                .then(a.column.cmp(&b.column))
140                .then_with(|| a.rule.cmp(&b.rule))
141        });
142        out
143    }
144
145    #[must_use]
146    pub fn len(&self) -> usize {
147        self.rules.len()
148    }
149
150    #[must_use]
151    pub fn is_empty(&self) -> bool {
152        self.rules.is_empty()
153    }
154}
155
156/// Consumes the set and yields its rules in insertion order.
157///
158/// Required by the CLI's `--rules` selector, which partitions the
159/// available pool of rules into the user-requested subset without
160/// cloning trait objects (`LintRule` is not `Clone`).
161impl IntoIterator for RuleSet {
162    type Item = Box<dyn LintRule>;
163    type IntoIter = std::vec::IntoIter<Box<dyn LintRule>>;
164
165    fn into_iter(self) -> Self::IntoIter {
166        self.rules.into_iter()
167    }
168}
169
170impl fmt::Debug for RuleSet {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.debug_struct("RuleSet")
173            .field("rules", &self.rules.iter().map(|r| r.name()).collect::<Vec<_>>())
174            .finish()
175    }
176}
177
178/// Error returned by [`RuleSet::add`] when a name collides with an
179/// already-registered rule.
180#[derive(Debug, Clone)]
181pub struct DuplicateRuleName {
182    pub name: String,
183}
184
185impl fmt::Display for DuplicateRuleName {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        write!(f, "rule already registered: {}", self.name)
188    }
189}
190
191impl std::error::Error for DuplicateRuleName {}
192
193#[cfg(test)]
194mod tests {
195    use super::{DuplicateRuleName, RuleSet};
196    use crate::diagnostic::Diagnostic;
197    use crate::rule::LintRule;
198    use mdwright_document::Document;
199
200    struct Noop(&'static str);
201    impl LintRule for Noop {
202        fn name(&self) -> &str {
203            self.0
204        }
205        fn description(&self) -> &str {
206            "noop"
207        }
208        fn check(&self, _doc: &Document, _out: &mut Vec<Diagnostic>) {}
209    }
210
211    #[test]
212    fn add_and_contains() -> anyhow::Result<()> {
213        let mut rs = RuleSet::new();
214        rs.add(Box::new(Noop("a"))).map_err(|e| anyhow::anyhow!("{e}"))?;
215        assert!(rs.contains("a"));
216        assert!(!rs.contains("b"));
217        Ok(())
218    }
219
220    #[test]
221    fn duplicate_add_errors() -> anyhow::Result<()> {
222        let mut rs = RuleSet::new();
223        rs.add(Box::new(Noop("a"))).map_err(|e| anyhow::anyhow!("{e}"))?;
224        let err = rs.add(Box::new(Noop("a")));
225        assert!(matches!(err, Err(DuplicateRuleName { ref name }) if name == "a"));
226        Ok(())
227    }
228
229    #[test]
230    fn remove_works() -> anyhow::Result<()> {
231        let mut rs = RuleSet::new();
232        rs.add(Box::new(Noop("a"))).map_err(|e| anyhow::anyhow!("{e}"))?;
233        assert!(rs.remove("a"));
234        assert!(!rs.remove("a"));
235        assert!(!rs.contains("a"));
236        Ok(())
237    }
238
239    #[test]
240    fn by_name_finds_or_returns_none() -> anyhow::Result<()> {
241        let mut rs = RuleSet::new();
242        rs.add(Box::new(Noop("a"))).map_err(|e| anyhow::anyhow!("{e}"))?;
243        rs.add(Box::new(Noop("b"))).map_err(|e| anyhow::anyhow!("{e}"))?;
244        assert_eq!(rs.by_name("a").map(LintRule::name), Some("a"));
245        assert_eq!(rs.by_name("b").map(LintRule::name), Some("b"));
246        assert!(rs.by_name("c").is_none());
247        Ok(())
248    }
249
250    #[test]
251    fn names_iterates_in_insertion_order() -> anyhow::Result<()> {
252        let mut rs = RuleSet::new();
253        rs.add(Box::new(Noop("alpha"))).map_err(|e| anyhow::anyhow!("{e}"))?;
254        rs.add(Box::new(Noop("beta"))).map_err(|e| anyhow::anyhow!("{e}"))?;
255        rs.add(Box::new(Noop("gamma"))).map_err(|e| anyhow::anyhow!("{e}"))?;
256        let collected: Vec<&str> = rs.names().collect();
257        assert_eq!(collected, vec!["alpha", "beta", "gamma"]);
258        Ok(())
259    }
260
261    struct MultiCode;
262    impl LintRule for MultiCode {
263        fn name(&self) -> &str {
264            "umbrella"
265        }
266        fn description(&self) -> &str {
267            "emits diagnostics under several rule codes"
268        }
269        fn check(&self, _doc: &Document, out: &mut Vec<Diagnostic>) {
270            out.push(Diagnostic {
271                rule: std::borrow::Cow::Borrowed("umbrella/sub-a"),
272                line: 1,
273                column: 1,
274                span: 0..0,
275                message: String::new(),
276                fix: None,
277                advisory: false,
278            });
279            out.push(Diagnostic {
280                rule: std::borrow::Cow::Borrowed(""),
281                line: 1,
282                column: 2,
283                span: 0..0,
284                message: String::new(),
285                fix: None,
286                advisory: false,
287            });
288        }
289    }
290
291    #[test]
292    fn pre_stamped_rule_codes_survive_dispatch() -> anyhow::Result<()> {
293        let mut rs = RuleSet::new();
294        rs.add(Box::new(MultiCode)).map_err(|e| anyhow::anyhow!("{e}"))?;
295        let doc = Document::parse("")?;
296        let diagnostics = rs.check(&doc);
297        let codes: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_ref()).collect();
298        assert_eq!(codes, vec!["umbrella/sub-a", "umbrella"]);
299        Ok(())
300    }
301
302    #[test]
303    fn into_iter_yields_owned_boxes_in_insertion_order() -> anyhow::Result<()> {
304        let mut rs = RuleSet::new();
305        rs.add(Box::new(Noop("first"))).map_err(|e| anyhow::anyhow!("{e}"))?;
306        rs.add(Box::new(Noop("second"))).map_err(|e| anyhow::anyhow!("{e}"))?;
307        let names: Vec<String> = rs.into_iter().map(|r| r.name().to_owned()).collect();
308        assert_eq!(names, vec!["first".to_owned(), "second".to_owned()]);
309        Ok(())
310    }
311}