1use 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
39struct 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
69struct 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 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 let canonical = normalize_rule_name(written);
125 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 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 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 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 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 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 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 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 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}