1use 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 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 let canonical = normalize_rule_name(written);
50 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 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 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 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 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 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 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}