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 std::collections::{HashMap, HashSet};
27
28use crate::inline_config::{DisableLayer, DisableScope, DisableSite, collect_disable_sites, normalize_rule_name};
29use crate::lint_context::{LintContext, SourcePositions};
30use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity, SuppressionReport};
31
32#[derive(Default)]
33struct SuppressionUse {
34    file: bool,
35    last_block_line: usize,
36    lines: HashSet<usize>,
37}
38
39/// Keep each suppression layer separate: a file disable must not give a line
40/// comment credit. Block scopes reach every later line, so only their last
41/// suppressed line is needed to answer whether a block comment was used.
42struct SuppressionIndex<'a>(HashMap<&'a str, SuppressionUse>);
43
44impl<'a> SuppressionIndex<'a> {
45    fn new(report: &'a SuppressionReport) -> Self {
46        let mut uses: HashMap<&str, SuppressionUse> = HashMap::new();
47        for warning in &report.suppressed {
48            let usage = uses.entry(&warning.rule_name).or_default();
49            match warning.layer {
50                DisableLayer::File => usage.file = true,
51                DisableLayer::Block => usage.last_block_line = usage.last_block_line.max(warning.line),
52                DisableLayer::Line => {
53                    usage.lines.insert(warning.line);
54                }
55            }
56        }
57        Self(uses)
58    }
59
60    fn used(&self, rule: &str, scope: DisableScope) -> bool {
61        self.0.get(rule).is_some_and(|usage| match scope {
62            DisableScope::File => usage.file,
63            DisableScope::Block(start) => start < usage.last_block_line,
64            DisableScope::Line(line) => usage.lines.contains(&line),
65        })
66    }
67}
68
69/// Keep cheap early matches cheap. Across all queries, scan at most two reports'
70/// worth of entries before switching to indexed lookups. Cheap matches for
71/// broad disable comments do not need an index.
72struct SuppressionLookup<'a> {
73    report: &'a SuppressionReport,
74    scans_left: usize,
75    index: std::cell::OnceCell<SuppressionIndex<'a>>,
76}
77
78impl<'a> SuppressionLookup<'a> {
79    fn new(report: &'a SuppressionReport) -> Self {
80        Self {
81            report,
82            scans_left: report.suppressed.len(),
83            index: std::cell::OnceCell::new(),
84        }
85    }
86
87    fn used(&mut self, rule: &str, scope: DisableScope) -> bool {
88        if self.scans_left > 0 {
89            let found = self
90                .report
91                .suppressed
92                .iter()
93                .position(|warning| warning.rule_name == rule && scope.carries(warning.layer, warning.line));
94            let scanned = found.map_or(self.report.suppressed.len(), |index| index + 1);
95            self.scans_left = self.scans_left.saturating_sub(scanned);
96            found.is_some()
97        } else {
98            self.index
99                .get_or_init(|| SuppressionIndex::new(self.report))
100                .used(rule, scope)
101        }
102    }
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct MD087UnusedDisableComment;
107
108impl MD087UnusedDisableComment {
109    pub fn new() -> Self {
110        Self
111    }
112
113    /// The rules a comment names that suppressed nothing, in the order written.
114    ///
115    /// A comment naming no rule returns nothing: it disables every rule, so the
116    /// findings of one run cannot show that it silenced nothing.
117    fn unused_rules(&self, site: &DisableSite, lookup: &mut SuppressionLookup<'_>) -> Vec<String> {
118        let mut unused: Vec<String> = Vec::new();
119
120        for written in &site.rules {
121            // The same canonicalization the inline config applies, so a comment is
122            // judged against the rule it really disables. An unrecognized name
123            // canonicalizes to nothing the run carries and drops out below.
124            let canonical = normalize_rule_name(written);
125            // This rule's own findings are raised after the report is assembled,
126            // so a comment silencing them cannot appear in it.
127            if canonical == self.name() || !lookup.report.judged_rules.contains(&canonical) {
128                continue;
129            }
130            let used = lookup.used(&canonical, site.scope);
131            if !used && !unused.contains(written) {
132                unused.push(written.clone());
133            }
134        }
135
136        unused
137    }
138
139    fn diagnostic_position(ctx: &LintContext, positions: &mut SourcePositions<'_>, offset: usize) -> (usize, usize) {
140        let line = ctx.line_number_at_offset(offset);
141        if let Some(info) = ctx.line_info(line)
142            && offset <= info.byte_offset + info.byte_len
143        {
144            (line, positions.column_on_line(line - 1, offset) + 1)
145        } else {
146            // Multiline comments are clipped to split('\n') lines, which retain
147            // CR. Preserve scalar positions outside the content-only line table.
148            ctx.offset_to_line_col(offset)
149        }
150    }
151
152    fn warning(
153        &self,
154        ctx: &LintContext,
155        positions: &mut SourcePositions<'_>,
156        site: &DisableSite,
157        unused: &[String],
158    ) -> LintWarning {
159        let line_offset = ctx.line_info(site.line).map_or(0, |info| info.byte_offset);
160        let (line, column) = Self::diagnostic_position(ctx, positions, line_offset + site.span.start);
161        let (_, end_column) = Self::diagnostic_position(ctx, positions, line_offset + site.span.end);
162        let names = unused.join(", ");
163        // A configure-file comment configures rules rather than disabling a span,
164        // so it is named for what the entry does instead of what the comment is.
165        let message = if site.kind == "configure-file" {
166            format!("Unused configure-file disable: {names}")
167        } else {
168            format!("Unused {} comment: {names}", site.kind)
169        };
170        LintWarning {
171            rule_name: Some(self.name().to_string()),
172            severity: Severity::Warning,
173            line,
174            column,
175            end_line: line,
176            end_column,
177            message,
178            fix: None,
179        }
180    }
181}
182
183impl Rule for MD087UnusedDisableComment {
184    fn name(&self) -> &'static str {
185        "MD087"
186    }
187
188    fn description(&self) -> &'static str {
189        "Inline disable comments should suppress something"
190    }
191
192    fn category(&self) -> RuleCategory {
193        RuleCategory::Other
194    }
195
196    fn should_skip(&self, ctx: &LintContext) -> bool {
197        !ctx.content.contains("<!--")
198    }
199
200    fn check(&self, _ctx: &LintContext) -> LintResult {
201        // A comment is judged by what the rest of the run suppressed, which is
202        // only known once every other rule has finished. That arrives through
203        // check_suppressions.
204        Ok(Vec::new())
205    }
206
207    fn observes_suppressions(&self) -> bool {
208        true
209    }
210
211    fn check_suppressions(&self, ctx: &LintContext, report: &SuppressionReport) -> LintResult {
212        let mut warnings = Vec::new();
213        let sites = collect_disable_sites(ctx.content, &ctx.code_blocks, ctx.inline_code_ranges());
214        if sites.is_empty() {
215            return Ok(warnings);
216        }
217        let mut lookup = SuppressionLookup::new(report);
218        let mut positions = SourcePositions::new(ctx.content, &ctx.lines);
219        for site in sites {
220            let unused = self.unused_rules(&site, &mut lookup);
221            if unused.is_empty() {
222                continue;
223            }
224            warnings.push(self.warning(ctx, &mut positions, &site, &unused));
225        }
226        Ok(warnings)
227    }
228
229    fn fix_capability(&self) -> FixCapability {
230        FixCapability::Unfixable
231    }
232
233    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
234        Ok(ctx.content.to_string())
235    }
236
237    fn as_any(&self) -> &dyn std::any::Any {
238        self
239    }
240
241    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
242    where
243        Self: Sized,
244    {
245        Box::new(Self)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::config::MarkdownFlavor;
253    use crate::inline_config::DisableLayer;
254    use crate::rule::SuppressedWarning;
255    use std::collections::HashSet;
256
257    #[test]
258    fn suppression_index_matches_layer_and_scope_scans() {
259        let suppressed = [
260            ("MD013", DisableLayer::Block, 0),
261            ("MD013", DisableLayer::Block, 30),
262            ("MD013", DisableLayer::Block, 10),
263            ("MD001", DisableLayer::Line, 7),
264            ("md013", DisableLayer::File, 0),
265            ("MD013", DisableLayer::Line, 0),
266            ("MD001", DisableLayer::Line, usize::MAX),
267            ("MD013", DisableLayer::Block, usize::MAX),
268            ("MD001", DisableLayer::File, 2),
269        ];
270        for count in 0..=suppressed.len() {
271            let report = report(&suppressed[..count], &["MD013", "MD001"]);
272            let index = SuppressionIndex::new(&report);
273            let mut lookup = SuppressionLookup::new(&report);
274            for rule in ["MD013", "md013", "MD001", "MD087", "unknown"] {
275                for scope in [0, 1, 7, 10, 20, 30, usize::MAX]
276                    .into_iter()
277                    .flat_map(|line| [DisableScope::Line(line), DisableScope::Block(line)])
278                    .chain(std::iter::once(DisableScope::File))
279                {
280                    let expected = report
281                        .suppressed
282                        .iter()
283                        .any(|warning| warning.rule_name == rule && scope.carries(warning.layer, warning.line));
284                    assert_eq!(index.used(rule, scope), expected, "{rule} {scope:?}, {count} entries");
285                    assert_eq!(lookup.used(rule, scope), expected, "{rule} {scope:?}, {count} entries");
286                }
287            }
288        }
289    }
290
291    #[test]
292    fn broad_disables_keep_cheap_early_matches() {
293        let report = report(&[("MD013", DisableLayer::File, 1); 100], &["MD013"]);
294        let mut lookup = SuppressionLookup::new(&report);
295        for _ in 0..10 {
296            assert!(lookup.used("MD013", DisableScope::File));
297        }
298        assert!(
299            lookup.index.get().is_none(),
300            "early matches should not index the whole report"
301        );
302        assert!(!lookup.used("MD001", DisableScope::File));
303        assert!(lookup.used("MD013", DisableScope::File));
304        assert!(
305            lookup.index.get().is_some(),
306            "repeated scans should switch to the index"
307        );
308    }
309
310    #[test]
311    fn cached_comment_positions_match_scalar_positions() {
312        for content in [
313            String::new(),
314            "\n".into(),
315            "é日本🙂\r\n<!-- rumdl-disable-line MD013 -->\r\n".into(),
316            format!("{}<!-- rumdl-disable\nMD013 -->\r\n", "é日本🙂 ".repeat(80)),
317        ] {
318            let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
319            let mut positions = SourcePositions::new(&content, &ctx.lines);
320            // Also exercise backwards queries and endpoints outside the parser's
321            // content-only line table, including CRLF and trailing EOF.
322            for offset in (0..=content.len() + 1)
323                .chain((0..=content.len() + 1).rev())
324                .chain(std::iter::once(usize::MAX))
325            {
326                assert_eq!(
327                    MD087UnusedDisableComment::diagnostic_position(&ctx, &mut positions, offset),
328                    ctx.offset_to_line_col(offset),
329                    "{content:?}, byte {offset}"
330                );
331            }
332        }
333    }
334
335    fn report(suppressed: &[(&str, DisableLayer, usize)], judged: &[&str]) -> SuppressionReport {
336        SuppressionReport {
337            suppressed: suppressed
338                .iter()
339                .map(|&(rule_name, layer, line)| SuppressedWarning {
340                    rule_name: rule_name.to_string(),
341                    line,
342                    layer,
343                })
344                .collect(),
345            judged_rules: judged.iter().map(|name| (*name).to_string()).collect::<HashSet<_>>(),
346        }
347    }
348
349    fn check(content: &str, suppressed: &[(&str, DisableLayer, usize)], judged: &[&str]) -> Vec<LintWarning> {
350        check_in(MarkdownFlavor::Standard, content, suppressed, judged)
351    }
352
353    fn check_in(
354        flavor: MarkdownFlavor,
355        content: &str,
356        suppressed: &[(&str, DisableLayer, usize)],
357        judged: &[&str],
358    ) -> Vec<LintWarning> {
359        let ctx = LintContext::new(content, flavor, None);
360        MD087UnusedDisableComment::new()
361            .check_suppressions(&ctx, &report(suppressed, judged))
362            .unwrap()
363    }
364
365    #[test]
366    fn reports_a_compact_disable_line_comment_that_suppressed_nothing() {
367        let content = "# Title\n\nA short line <!--rumdl-disable-line MD013-->\n";
368        let warnings = check(content, &[], &["MD013"]);
369        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
370        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
371        // A compact comment that did suppress something is not reported.
372        let used = check(content, &[("MD013", DisableLayer::Line, 3)], &["MD013"]);
373        assert!(used.is_empty(), "got: {used:?}");
374    }
375
376    #[test]
377    fn reports_a_disable_line_comment_that_suppressed_nothing() {
378        let content = "# Title\n\nA short line <!-- rumdl-disable-line MD013 -->\n";
379        let warnings = check(content, &[], &["MD013"]);
380        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
381        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
382        assert_eq!((warnings[0].line, warnings[0].column), (3, 14));
383        assert_eq!(warnings[0].end_column, 47, "the warning spans the comment");
384        assert!(
385            warnings[0].fix.is_none(),
386            "removing an authored comment is not automatic"
387        );
388    }
389
390    #[test]
391    fn keeps_quiet_when_the_comment_suppressed_a_finding() {
392        let content = "# Title\n\nA short line <!-- rumdl-disable-line MD013 -->\n";
393        let warnings = check(content, &[("MD013", DisableLayer::Line, 3)], &["MD013"]);
394        assert!(warnings.is_empty(), "got: {warnings:?}");
395    }
396
397    #[test]
398    fn a_disable_line_comment_is_judged_on_its_own_line_only() {
399        let content = "<!-- rumdl-disable-line MD013 -->\nA long line\n";
400        let warnings = check(content, &[("MD013", DisableLayer::Line, 2)], &["MD013"]);
401        assert_eq!(warnings.len(), 1, "a finding on line 2 is not this comment's doing");
402        assert_eq!(warnings[0].line, 1);
403    }
404
405    #[test]
406    fn a_disable_next_line_comment_is_judged_on_the_following_line() {
407        let content = "<!-- rumdl-disable-next-line MD013 -->\nA long line\n";
408        assert!(
409            check(content, &[("MD013", DisableLayer::Line, 2)], &["MD013"]).is_empty(),
410            "the suppression on line 2 is what the comment is for"
411        );
412        let warnings = check(content, &[("MD013", DisableLayer::Line, 1)], &["MD013"]);
413        assert_eq!(warnings.len(), 1, "a finding on line 1 is not this comment's doing");
414        assert_eq!(warnings[0].message, "Unused disable-next-line comment: MD013");
415    }
416
417    #[test]
418    fn a_block_disable_reaches_the_end_of_the_document() {
419        let content = "<!-- rumdl-disable MD013 -->\n\ntext\n\n<!-- rumdl-enable MD013 -->\n\nmore\n";
420        assert!(
421            check(content, &[("MD013", DisableLayer::Block, 7)], &["MD013"]).is_empty(),
422            "a scope wider than the truth may only under-report"
423        );
424        let warnings = check(content, &[], &["MD013"]);
425        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
426        assert_eq!(warnings[0].message, "Unused disable comment: MD013");
427    }
428
429    #[test]
430    fn a_disable_file_comment_covers_a_finding_above_it() {
431        let content = "A long line\n\n<!-- rumdl-disable-file MD013 -->\n";
432        assert!(
433            check(content, &[("MD013", DisableLayer::File, 1)], &["MD013"]).is_empty(),
434            "disable-file applies to the whole document, including lines above it"
435        );
436    }
437
438    #[test]
439    fn a_comment_a_wider_one_already_covers_is_reported() {
440        let content = "<!-- rumdl-disable-file MD013 -->\n\nA long line <!-- rumdl-disable-line MD013 -->\n";
441        let warnings = check(content, &[("MD013", DisableLayer::File, 3)], &["MD013"]);
442        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
443        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
444        assert_eq!(warnings[0].line, 3, "the file-wide comment is the one doing the work");
445    }
446
447    #[test]
448    fn the_wider_comment_is_the_one_reported_when_the_narrow_one_does_the_work() {
449        // The converse of the case above: the block disable is closed before the
450        // finding, so the line comment is what keeps it quiet.
451        let content = "<!-- rumdl-disable MD013 -->\n<!-- rumdl-enable MD013 -->\nA long line <!-- rumdl-disable-line MD013 -->\n";
452        let warnings = check(content, &[("MD013", DisableLayer::Line, 3)], &["MD013"]);
453        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
454        assert_eq!(warnings[0].message, "Unused disable comment: MD013");
455        assert_eq!(warnings[0].line, 1);
456    }
457
458    #[test]
459    fn only_the_unused_names_of_a_multi_rule_comment_are_reported() {
460        let content = "text <!-- rumdl-disable-line MD013 MD033 MD009 -->\n";
461        let warnings = check(
462            content,
463            &[("MD033", DisableLayer::Line, 1)],
464            &["MD009", "MD013", "MD033"],
465        );
466        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
467        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013, MD009");
468    }
469
470    #[test]
471    fn a_rule_the_run_does_not_carry_is_not_judged() {
472        let content = "text <!-- rumdl-disable-line MD013 -->\n";
473        assert!(
474            check(content, &[], &["MD009"]).is_empty(),
475            "MD013 produced nothing because it did not run"
476        );
477    }
478
479    #[test]
480    fn an_unknown_rule_name_is_not_judged() {
481        let content = "text <!-- rumdl-disable-line MD999 -->\n";
482        assert!(
483            check(content, &[], &["MD013"]).is_empty(),
484            "MD999 is not a rule the run carries"
485        );
486    }
487
488    #[test]
489    fn a_comment_naming_no_rule_is_never_reported() {
490        let content = "text <!-- rumdl-disable-line -->\n<!-- rumdl-disable -->\n";
491        assert!(
492            check(content, &[], &["MD013"]).is_empty(),
493            "a bare comment disables rules this run may not carry"
494        );
495    }
496
497    #[test]
498    fn prettier_ignore_belongs_to_another_formatter() {
499        let content = "<!-- prettier-ignore -->\n| a | b |\n";
500        assert!(
501            check(content, &[], &["MD013"]).is_empty(),
502            "not rumdl's comment to judge"
503        );
504    }
505
506    #[test]
507    fn a_comment_inside_a_code_block_configures_nothing() {
508        let content = "# Title\n\n```markdown\n<!-- rumdl-disable-line MD013 -->\n```\n";
509        assert!(
510            check(content, &[], &["MD013"]).is_empty(),
511            "a fenced example documents a comment rather than writing one"
512        );
513    }
514
515    #[test]
516    fn a_comment_inside_a_code_span_configures_nothing() {
517        let content = "# Title\n\nSee `<!-- rumdl-disable-line MD013 -->` here.\n";
518        assert!(
519            check(content, &[], &["MD013"]).is_empty(),
520            "backticks show a comment rather than writing one"
521        );
522    }
523
524    #[test]
525    fn a_comment_beside_one_in_a_code_span_is_still_judged() {
526        // The positive control for the case above: a span speaks only for the
527        // comment it holds.
528        let content = "`<!-- rumdl-disable-line MD013 -->` <!-- rumdl-disable-line MD033 -->\n";
529        let warnings = check(content, &[], &["MD013", "MD033"]);
530        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
531        assert_eq!(warnings[0].message, "Unused disable-line comment: MD033");
532    }
533
534    #[test]
535    fn a_comment_in_an_indented_container_body_is_judged() {
536        // A MkDocs admonition holds its content at a four-space indent, which is
537        // structure rather than code, so the comment written there is live and
538        // stale once it suppresses nothing.
539        let content = "# Title\n\n!!! example\n\n    A short line <!-- rumdl-disable-line MD013 -->\n";
540        let warnings = check_in(MarkdownFlavor::MkDocs, content, &[], &["MD013"]);
541        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
542        assert_eq!(warnings[0].line, 5);
543        assert!(
544            check_in(MarkdownFlavor::Standard, content, &[], &["MD013"]).is_empty(),
545            "without admonitions the same lines are an indented code block"
546        );
547    }
548
549    #[test]
550    fn an_alias_is_reported_as_the_author_wrote_it() {
551        let content = "text <!-- rumdl-disable-line line-length -->\n";
552        let warnings = check(content, &[], &["MD013"]);
553        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
554        assert_eq!(warnings[0].message, "Unused disable-line comment: line-length");
555    }
556
557    #[test]
558    fn a_markdownlint_comment_is_judged_the_same_way() {
559        let content = "text <!-- markdownlint-disable-line MD013 -->\n";
560        let warnings = check(content, &[], &["MD013"]);
561        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
562        assert_eq!(warnings[0].message, "Unused disable-line comment: MD013");
563    }
564
565    #[test]
566    fn a_configure_file_entry_turning_a_rule_off_is_judged_like_a_disable() {
567        let content = "<!-- rumdl-configure-file { \"MD013\": false } -->\n\ntext\n";
568        let warnings = check(content, &[], &["MD013"]);
569        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
570        assert_eq!(warnings[0].message, "Unused configure-file disable: MD013");
571        assert!(
572            check(content, &[("MD013", DisableLayer::File, 3)], &["MD013"]).is_empty(),
573            "the entry turned the rule off for the whole file"
574        );
575    }
576
577    #[test]
578    fn a_configure_file_entry_carrying_options_is_not_a_disable() {
579        let content = "<!-- rumdl-configure-file { \"MD013\": { \"line_length\": 200 } } -->\n\ntext\n";
580        assert!(
581            check(content, &[], &["MD013"]).is_empty(),
582            "configuring a rule is not suppressing it"
583        );
584    }
585
586    #[test]
587    fn this_rule_never_judges_a_comment_silencing_itself() {
588        let content = "text <!-- rumdl-disable-line MD087 -->\n";
589        assert!(
590            check(content, &[], &["MD013", "MD087"]).is_empty(),
591            "MD087 findings are raised after the report is assembled"
592        );
593    }
594
595    #[test]
596    fn the_column_is_measured_in_characters() {
597        let content = "héllo wörld <!-- rumdl-disable-line MD013 -->\n";
598        let warnings = check(content, &[], &["MD013"]);
599        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
600        assert_eq!(warnings[0].column, 13, "two multi-byte characters precede the comment");
601    }
602
603    #[test]
604    fn check_reports_nothing_on_its_own() {
605        let content = "text <!-- rumdl-disable-line MD013 -->\n";
606        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
607        assert!(
608            MD087UnusedDisableComment::new().check(&ctx).unwrap().is_empty(),
609            "the verdict needs the run's suppressions"
610        );
611    }
612}