Skip to main content

mdwright_lint/stdlib/
trailing_whitespace.rs

1//! Non-hard-break trailing whitespace at the end of source lines.
2//!
3//! A line ending in `  \n` (two spaces) is a `CommonMark` hard break —
4//! left alone. Any other run of trailing whitespace is a typo or a
5//! leftover from a text editor: it's invisible, it churns diffs, and
6//! `git apply` sometimes refuses it. Lines inside code blocks are
7//! skipped — whitespace there can be meaningful.
8
9use crate::diagnostic::{Diagnostic, Fix};
10use crate::rule::LintRule;
11use mdwright_document::Document;
12
13pub struct TrailingWhitespace;
14
15impl LintRule for TrailingWhitespace {
16    fn name(&self) -> &str {
17        "trailing-whitespace"
18    }
19
20    fn description(&self) -> &str {
21        "Trailing whitespace at end of line."
22    }
23
24    fn explain(&self) -> &str {
25        include_str!("explain/trailing_whitespace.md")
26    }
27
28    fn produces_fix(&self) -> bool {
29        true
30    }
31
32    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
33        let source = doc.source();
34        let code_blocks = doc.code_blocks();
35        let mut line_start: usize = 0;
36        for line in source.split_inclusive('\n') {
37            let line_end_incl = line_start.saturating_add(line.len());
38            // Strip the trailing newline (if any) for whitespace
39            // analysis; keep the original `\n` boundary for the
40            // diagnostic span.
41            let (content, newline_len) = line.strip_suffix('\n').map_or((line, 0), |c| (c, 1));
42            let content_end = line_start.saturating_add(content.len());
43            let trail = content.bytes().rev().take_while(|b| matches!(b, b' ' | b'\t')).count();
44            if trail == 0 {
45                line_start = line_end_incl;
46                continue;
47            }
48            // Hard-break: exactly two trailing spaces. Leave alone.
49            if trail == 2 && content.bytes().rev().take(2).all(|b| b == b' ') && content.len() > 2 {
50                line_start = line_end_incl;
51                continue;
52            }
53            let span_start = content_end.saturating_sub(trail);
54            let span_end = content_end;
55            let inside_code = code_blocks
56                .iter()
57                .any(|c| c.raw_range.start <= span_start && span_start < c.raw_range.end);
58            if inside_code {
59                line_start = line_end_incl;
60                continue;
61            }
62            let local = 0..(span_end.saturating_sub(span_start));
63            if let Some(d) = Diagnostic::at(
64                doc,
65                span_start,
66                local,
67                "trailing whitespace".to_owned(),
68                Some(Fix {
69                    replacement: String::new(),
70                    safe: true,
71                }),
72            ) {
73                out.push(d);
74            }
75            // suppress unused-warning on newline_len without churn
76            let _ = newline_len;
77            line_start = line_end_incl;
78        }
79    }
80}