Skip to main content

rumdl_lib/rules/
md087_unused_disable_comment.rs

1//! Rule MD087: Inline disable comments should suppress something.
2//!
3//! A `<!-- rumdl-disable-line MD013 -->` written to silence a finding stays behind
4//! after the line is rewritten or the rule stops reporting it. Nothing else
5//! notices: a comment that suppresses nothing costs no findings, so it survives
6//! every run and quietly widens the set of rules that cannot report on that line
7//! again.
8//!
9//! The rule judges a comment by what the run around it actually suppressed, so it
10//! reports only what the current configuration makes unnecessary. A comment a
11//! wider one already covers is reported too: with the rule off for the whole file,
12//! a `disable-line` naming it silences nothing of its own, and the wider comment
13//! keeps the line quiet once the narrower one is gone. Three kinds of comment are
14//! deliberately left alone:
15//!
16//! - one naming a rule this run does not carry, since a rule configuration turned
17//!   off produced nothing and its comment cannot be judged by that silence
18//! - one naming no rule at all, which disables every rule at once, including ones
19//!   a given run may not carry
20//! - `<!-- prettier-ignore -->`, which belongs to another formatter
21//!
22//! Detection only. Removing a comment is a content decision: the author may be
23//! about to restore the line that needed it, and `rumdl fmt` must not delete
24//! authored comments on its own.
25
26use crate::inline_config::{DisableSite, collect_disable_sites, normalize_rule_name};
27use crate::lint_context::LintContext;
28use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity, SuppressionReport};
29
30#[derive(Debug, Clone, Default)]
31pub struct MD087UnusedDisableComment;
32
33impl MD087UnusedDisableComment {
34    pub fn new() -> Self {
35        Self
36    }
37
38    /// The rules a comment names that suppressed nothing, in the order written.
39    ///
40    /// A comment naming no rule returns nothing: it disables every rule, so the
41    /// findings of one run cannot show that it silenced nothing.
42    fn unused_rules(&self, site: &DisableSite, report: &SuppressionReport) -> Vec<String> {
43        let mut unused: Vec<String> = Vec::new();
44
45        for written in &site.rules {
46            // The same canonicalization the inline config applies, so a comment is
47            // judged against the rule it really disables. An unrecognized name
48            // canonicalizes to nothing the run carries and drops out below.
49            let canonical = normalize_rule_name(written);
50            // This rule's own findings are raised after the report is assembled,
51            // so a comment silencing them cannot appear in it.
52            if canonical == self.name() || !report.judged_rules.contains(&canonical) {
53                continue;
54            }
55            let used = report
56                .suppressed
57                .iter()
58                .any(|warning| warning.rule_name == canonical && site.scope.carries(warning.layer, warning.line));
59            if !used && !unused.contains(written) {
60                unused.push(written.clone());
61            }
62        }
63
64        unused
65    }
66
67    fn warning(&self, ctx: &LintContext, site: &DisableSite, unused: &[String]) -> LintWarning {
68        let line_offset = ctx.line_info(site.line).map_or(0, |info| info.byte_offset);
69        let (line, column) = ctx.offset_to_line_col(line_offset + site.span.start);
70        let (_, end_column) = ctx.offset_to_line_col(line_offset + site.span.end);
71        let names = unused.join(", ");
72        // A configure-file comment configures rules rather than disabling a span,
73        // so it is named for what the entry does instead of what the comment is.
74        let message = if site.kind == "configure-file" {
75            format!("Unused configure-file disable: {names}")
76        } else {
77            format!("Unused {} comment: {names}", site.kind)
78        };
79        LintWarning {
80            rule_name: Some(self.name().to_string()),
81            severity: Severity::Warning,
82            line,
83            column,
84            end_line: line,
85            end_column,
86            message,
87            fix: None,
88        }
89    }
90}
91
92impl Rule for MD087UnusedDisableComment {
93    fn name(&self) -> &'static str {
94        "MD087"
95    }
96
97    fn description(&self) -> &'static str {
98        "Inline disable comments should suppress something"
99    }
100
101    fn category(&self) -> RuleCategory {
102        RuleCategory::Other
103    }
104
105    fn should_skip(&self, ctx: &LintContext) -> bool {
106        !ctx.content.contains("<!--")
107    }
108
109    fn check(&self, _ctx: &LintContext) -> LintResult {
110        // A comment is judged by what the rest of the run suppressed, which is
111        // only known once every other rule has finished. That arrives through
112        // check_suppressions.
113        Ok(Vec::new())
114    }
115
116    fn observes_suppressions(&self) -> bool {
117        true
118    }
119
120    fn check_suppressions(&self, ctx: &LintContext, report: &SuppressionReport) -> LintResult {
121        let mut warnings = Vec::new();
122        for site in collect_disable_sites(ctx.content, &ctx.code_blocks, ctx.inline_code_ranges()) {
123            let unused = self.unused_rules(&site, report);
124            if unused.is_empty() {
125                continue;
126            }
127            warnings.push(self.warning(ctx, &site, &unused));
128        }
129        Ok(warnings)
130    }
131
132    fn fix_capability(&self) -> FixCapability {
133        FixCapability::Unfixable
134    }
135
136    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
137        Ok(ctx.content.to_string())
138    }
139
140    fn as_any(&self) -> &dyn std::any::Any {
141        self
142    }
143
144    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
145    where
146        Self: Sized,
147    {
148        Box::new(Self)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::config::MarkdownFlavor;
156    use crate::inline_config::DisableLayer;
157    use crate::rule::SuppressedWarning;
158    use std::collections::HashSet;
159
160    fn report(suppressed: &[(&str, DisableLayer, usize)], judged: &[&str]) -> SuppressionReport {
161        SuppressionReport {
162            suppressed: suppressed
163                .iter()
164                .map(|&(rule_name, layer, line)| SuppressedWarning {
165                    rule_name: rule_name.to_string(),
166                    line,
167                    layer,
168                })
169                .collect(),
170            judged_rules: judged.iter().map(|name| (*name).to_string()).collect::<HashSet<_>>(),
171        }
172    }
173
174    fn check(content: &str, suppressed: &[(&str, DisableLayer, usize)], judged: &[&str]) -> Vec<LintWarning> {
175        check_in(MarkdownFlavor::Standard, content, suppressed, judged)
176    }
177
178    fn check_in(
179        flavor: MarkdownFlavor,
180        content: &str,
181        suppressed: &[(&str, DisableLayer, usize)],
182        judged: &[&str],
183    ) -> Vec<LintWarning> {
184        let ctx = LintContext::new(content, flavor, None);
185        MD087UnusedDisableComment::new()
186            .check_suppressions(&ctx, &report(suppressed, judged))
187            .unwrap()
188    }
189
190    #[test]
191    fn reports_a_compact_disable_line_comment_that_suppressed_nothing() {
192        let content = "# Title\n\nA short line <!--rumdl-disable-line MD013-->\n";
193        let warnings = check(content, &[], &["MD013"]);
194        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
195        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
196        // A compact comment that did suppress something is not reported.
197        let used = check(content, &[("MD013", DisableLayer::Line, 3)], &["MD013"]);
198        assert!(used.is_empty(), "got: {used:?}");
199    }
200
201    #[test]
202    fn reports_a_disable_line_comment_that_suppressed_nothing() {
203        let content = "# Title\n\nA short line <!-- rumdl-disable-line MD013 -->\n";
204        let warnings = check(content, &[], &["MD013"]);
205        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
206        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
207        assert_eq!((warnings[0].line, warnings[0].column), (3, 14));
208        assert_eq!(warnings[0].end_column, 47, "the warning spans the comment");
209        assert!(
210            warnings[0].fix.is_none(),
211            "removing an authored comment is not automatic"
212        );
213    }
214
215    #[test]
216    fn keeps_quiet_when_the_comment_suppressed_a_finding() {
217        let content = "# Title\n\nA short line <!-- rumdl-disable-line MD013 -->\n";
218        let warnings = check(content, &[("MD013", DisableLayer::Line, 3)], &["MD013"]);
219        assert!(warnings.is_empty(), "got: {warnings:?}");
220    }
221
222    #[test]
223    fn a_disable_line_comment_is_judged_on_its_own_line_only() {
224        let content = "<!-- rumdl-disable-line MD013 -->\nA long line\n";
225        let warnings = check(content, &[("MD013", DisableLayer::Line, 2)], &["MD013"]);
226        assert_eq!(warnings.len(), 1, "a finding on line 2 is not this comment's doing");
227        assert_eq!(warnings[0].line, 1);
228    }
229
230    #[test]
231    fn a_disable_next_line_comment_is_judged_on_the_following_line() {
232        let content = "<!-- rumdl-disable-next-line MD013 -->\nA long line\n";
233        assert!(
234            check(content, &[("MD013", DisableLayer::Line, 2)], &["MD013"]).is_empty(),
235            "the suppression on line 2 is what the comment is for"
236        );
237        let warnings = check(content, &[("MD013", DisableLayer::Line, 1)], &["MD013"]);
238        assert_eq!(warnings.len(), 1, "a finding on line 1 is not this comment's doing");
239        assert_eq!(warnings[0].message, "Unused disable-next-line comment: MD013");
240    }
241
242    #[test]
243    fn a_block_disable_reaches_the_end_of_the_document() {
244        let content = "<!-- rumdl-disable MD013 -->\n\ntext\n\n<!-- rumdl-enable MD013 -->\n\nmore\n";
245        assert!(
246            check(content, &[("MD013", DisableLayer::Block, 7)], &["MD013"]).is_empty(),
247            "a scope wider than the truth may only under-report"
248        );
249        let warnings = check(content, &[], &["MD013"]);
250        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
251        assert_eq!(warnings[0].message, "Unused disable comment: MD013");
252    }
253
254    #[test]
255    fn a_disable_file_comment_covers_a_finding_above_it() {
256        let content = "A long line\n\n<!-- rumdl-disable-file MD013 -->\n";
257        assert!(
258            check(content, &[("MD013", DisableLayer::File, 1)], &["MD013"]).is_empty(),
259            "disable-file applies to the whole document, including lines above it"
260        );
261    }
262
263    #[test]
264    fn a_comment_a_wider_one_already_covers_is_reported() {
265        let content = "<!-- rumdl-disable-file MD013 -->\n\nA long line <!-- rumdl-disable-line MD013 -->\n";
266        let warnings = check(content, &[("MD013", DisableLayer::File, 3)], &["MD013"]);
267        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
268        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
269        assert_eq!(warnings[0].line, 3, "the file-wide comment is the one doing the work");
270    }
271
272    #[test]
273    fn the_wider_comment_is_the_one_reported_when_the_narrow_one_does_the_work() {
274        // The converse of the case above: the block disable is closed before the
275        // finding, so the line comment is what keeps it quiet.
276        let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-enable MD013 -->\nA long line <!-- rumdl-disable-line MD013 -->\n";
277        let warnings = check(content, &[("MD013", DisableLayer::Line, 3)], &["MD013"]);
278        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
279        assert_eq!(warnings[0].message, "Unused disable comment: MD013");
280        assert_eq!(warnings[0].line, 1);
281    }
282
283    #[test]
284    fn only_the_unused_names_of_a_multi_rule_comment_are_reported() {
285        let content = "text <!-- rumdl-disable-line MD013 MD033 MD009 -->\n";
286        let warnings = check(
287            content,
288            &[("MD033", DisableLayer::Line, 1)],
289            &["MD009", "MD013", "MD033"],
290        );
291        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
292        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013, MD009");
293    }
294
295    #[test]
296    fn a_rule_the_run_does_not_carry_is_not_judged() {
297        let content = "text <!-- rumdl-disable-line MD013 -->\n";
298        assert!(
299            check(content, &[], &["MD009"]).is_empty(),
300            "MD013 produced nothing because it did not run"
301        );
302    }
303
304    #[test]
305    fn an_unknown_rule_name_is_not_judged() {
306        let content = "text <!-- rumdl-disable-line MD999 -->\n";
307        assert!(
308            check(content, &[], &["MD013"]).is_empty(),
309            "MD999 is not a rule the run carries"
310        );
311    }
312
313    #[test]
314    fn a_comment_naming_no_rule_is_never_reported() {
315        let content = "text <!-- rumdl-disable-line -->\n<!-- rumdl-disable -->\n";
316        assert!(
317            check(content, &[], &["MD013"]).is_empty(),
318            "a bare comment disables rules this run may not carry"
319        );
320    }
321
322    #[test]
323    fn prettier_ignore_belongs_to_another_formatter() {
324        let content = "<!-- prettier-ignore -->\n| a | b |\n";
325        assert!(
326            check(content, &[], &["MD013"]).is_empty(),
327            "not rumdl's comment to judge"
328        );
329    }
330
331    #[test]
332    fn a_comment_inside_a_code_block_configures_nothing() {
333        let content = "# Title\n\n```markdown\n<!-- rumdl-disable-line MD013 -->\n```\n";
334        assert!(
335            check(content, &[], &["MD013"]).is_empty(),
336            "a fenced example documents a comment rather than writing one"
337        );
338    }
339
340    #[test]
341    fn a_comment_inside_a_code_span_configures_nothing() {
342        let content = "# Title\n\nSee `<!-- rumdl-disable-line MD013 -->` here.\n";
343        assert!(
344            check(content, &[], &["MD013"]).is_empty(),
345            "backticks show a comment rather than writing one"
346        );
347    }
348
349    #[test]
350    fn a_comment_beside_one_in_a_code_span_is_still_judged() {
351        // The positive control for the case above: a span speaks only for the
352        // comment it holds.
353        let content = "`<!-- rumdl-disable-line MD013 -->` <!-- rumdl-disable-line MD033 -->\n";
354        let warnings = check(content, &[], &["MD013", "MD033"]);
355        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
356        assert_eq!(warnings[0].message, "Unused disable-line comment: MD033");
357    }
358
359    #[test]
360    fn a_comment_in_an_indented_container_body_is_judged() {
361        // A MkDocs admonition holds its content at a four-space indent, which is
362        // structure rather than code, so the comment written there is live and
363        // stale once it suppresses nothing.
364        let content = "# Title\n\n!!! example\n\n    A short line <!-- rumdl-disable-line MD013 -->\n";
365        let warnings = check_in(MarkdownFlavor::MkDocs, content, &[], &["MD013"]);
366        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
367        assert_eq!(warnings[0].line, 5);
368        assert!(
369            check_in(MarkdownFlavor::Standard, content, &[], &["MD013"]).is_empty(),
370            "without admonitions the same lines are an indented code block"
371        );
372    }
373
374    #[test]
375    fn an_alias_is_reported_as_the_author_wrote_it() {
376        let content = "text <!-- rumdl-disable-line line-length -->\n";
377        let warnings = check(content, &[], &["MD013"]);
378        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
379        assert_eq!(warnings[0].message, "Unused disable-line comment: line-length");
380    }
381
382    #[test]
383    fn a_markdownlint_comment_is_judged_the_same_way() {
384        let content = "text <!-- markdownlint-disable-line MD013 -->\n";
385        let warnings = check(content, &[], &["MD013"]);
386        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
387        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
388    }
389
390    #[test]
391    fn a_configure_file_entry_turning_a_rule_off_is_judged_like_a_disable() {
392        let content = "<!-- rumdl-configure-file { \"MD013\": false } -->\n\ntext\n";
393        let warnings = check(content, &[], &["MD013"]);
394        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
395        assert_eq!(warnings[0].message, "Unused configure-file disable: MD013");
396        assert!(
397            check(content, &[("MD013", DisableLayer::File, 3)], &["MD013"]).is_empty(),
398            "the entry turned the rule off for the whole file"
399        );
400    }
401
402    #[test]
403    fn a_configure_file_entry_carrying_options_is_not_a_disable() {
404        let content = "<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 200 } } -->\n\ntext\n";
405        assert!(
406            check(content, &[], &["MD013"]).is_empty(),
407            "configuring a rule is not suppressing it"
408        );
409    }
410
411    #[test]
412    fn this_rule_never_judges_a_comment_silencing_itself() {
413        let content = "text <!-- rumdl-disable-line MD087 -->\n";
414        assert!(
415            check(content, &[], &["MD013", "MD087"]).is_empty(),
416            "MD087 findings are raised after the report is assembled"
417        );
418    }
419
420    #[test]
421    fn the_column_is_measured_in_characters() {
422        let content = "héllo wörld <!-- rumdl-disable-line MD013 -->\n";
423        let warnings = check(content, &[], &["MD013"]);
424        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
425        assert_eq!(warnings[0].column, 13, "two multi-byte characters precede the comment");
426    }
427
428    #[test]
429    fn check_reports_nothing_on_its_own() {
430        let content = "text <!-- rumdl-disable-line MD013 -->\n";
431        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
432        assert!(
433            MD087UnusedDisableComment::new().check(&ctx).unwrap().is_empty(),
434            "the verdict needs the run's suppressions"
435        );
436    }
437}