Skip to main content

alint_rules/fixers/
hygiene.rs

1use std::io::Write;
2
3use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
4
5/// Strips trailing space/tab on every line of each violating
6/// file. Preserves original line endings (LF stays LF, CRLF
7/// stays CRLF).
8#[derive(Debug)]
9pub struct FileTrimTrailingWhitespaceFixer;
10
11impl Fixer for FileTrimTrailingWhitespaceFixer {
12    fn describe(&self) -> String {
13        "strip trailing whitespace on every line".to_string()
14    }
15
16    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
17        let Some(path) = &violation.path else {
18            return Ok(FixOutcome::Skipped(
19                "violation did not carry a path".to_string(),
20            ));
21        };
22        let abs = ctx.root.join(path);
23        if ctx.dry_run {
24            return Ok(FixOutcome::Applied(format!(
25                "would trim trailing whitespace in {}",
26                path.display()
27            )));
28        }
29        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
30            alint_core::ReadForFix::Bytes(b) => b,
31            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
32        };
33        let Ok(text) = std::str::from_utf8(&existing) else {
34            return Ok(FixOutcome::Skipped(format!(
35                "{} is not UTF-8; cannot trim",
36                path.display()
37            )));
38        };
39        let trimmed = strip_trailing_whitespace(text);
40        if trimmed.as_bytes() == existing {
41            return Ok(FixOutcome::Skipped(format!(
42                "{} already clean",
43                path.display()
44            )));
45        }
46        std::fs::write(&abs, trimmed.as_bytes()).map_err(|source| Error::Io {
47            path: abs.clone(),
48            source,
49        })?;
50        Ok(FixOutcome::Applied(format!(
51            "trimmed trailing whitespace in {}",
52            path.display()
53        )))
54    }
55}
56
57fn strip_trailing_whitespace(text: &str) -> String {
58    let mut out = String::with_capacity(text.len());
59    let mut first = true;
60    for line in text.split('\n') {
61        if !first {
62            out.push('\n');
63        }
64        first = false;
65        // Preserve CR before the (upcoming) LF so CRLF endings survive.
66        let (body, cr) = match line.strip_suffix('\r') {
67            Some(stripped) => (stripped, "\r"),
68            None => (line, ""),
69        };
70        out.push_str(body.trim_end_matches([' ', '\t']));
71        out.push_str(cr);
72    }
73    out
74}
75
76/// Appends a single `\n` byte when a file has content but
77/// doesn't end with one.
78#[derive(Debug)]
79pub struct FileAppendFinalNewlineFixer;
80
81impl Fixer for FileAppendFinalNewlineFixer {
82    fn describe(&self) -> String {
83        "append final newline when missing".to_string()
84    }
85
86    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
87        let Some(path) = &violation.path else {
88            return Ok(FixOutcome::Skipped(
89                "violation did not carry a path".to_string(),
90            ));
91        };
92        let abs = ctx.root.join(path);
93        if ctx.dry_run {
94            return Ok(FixOutcome::Applied(format!(
95                "would append final newline to {}",
96                path.display()
97            )));
98        }
99        if let Some(skip) = alint_core::check_fix_size(&abs, path, ctx)? {
100            return Ok(skip);
101        }
102        let mut f = std::fs::OpenOptions::new()
103            .append(true)
104            .open(&abs)
105            .map_err(|source| Error::Io {
106                path: abs.clone(),
107                source,
108            })?;
109        f.write_all(b"\n").map_err(|source| Error::Io {
110            path: abs.clone(),
111            source,
112        })?;
113        Ok(FixOutcome::Applied(format!(
114            "appended final newline to {}",
115            path.display()
116        )))
117    }
118}
119
120/// Which line ending [`FileNormalizeLineEndingsFixer`] rewrites to.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum LineEndingTarget {
123    Lf,
124    Crlf,
125}
126
127impl LineEndingTarget {
128    pub fn name(self) -> &'static str {
129        match self {
130            Self::Lf => "lf",
131            Self::Crlf => "crlf",
132        }
133    }
134
135    fn bytes(self) -> &'static [u8] {
136        match self {
137            Self::Lf => b"\n",
138            Self::Crlf => b"\r\n",
139        }
140    }
141}
142
143/// Rewrites every line ending in a file to the target (`lf` or `crlf`).
144#[derive(Debug)]
145pub struct FileNormalizeLineEndingsFixer {
146    target: LineEndingTarget,
147}
148
149impl FileNormalizeLineEndingsFixer {
150    pub fn new(target: LineEndingTarget) -> Self {
151        Self { target }
152    }
153}
154
155impl Fixer for FileNormalizeLineEndingsFixer {
156    fn describe(&self) -> String {
157        format!("normalize line endings to {}", self.target.name())
158    }
159
160    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
161        let Some(path) = &violation.path else {
162            return Ok(FixOutcome::Skipped(
163                "violation did not carry a path".to_string(),
164            ));
165        };
166        let abs = ctx.root.join(path);
167        if ctx.dry_run {
168            return Ok(FixOutcome::Applied(format!(
169                "would normalize line endings in {} to {}",
170                path.display(),
171                self.target.name()
172            )));
173        }
174        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
175            alint_core::ReadForFix::Bytes(b) => b,
176            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
177        };
178        let normalized = normalize_line_endings(&existing, self.target);
179        if normalized == existing {
180            return Ok(FixOutcome::Skipped(format!(
181                "{} already {}",
182                path.display(),
183                self.target.name()
184            )));
185        }
186        std::fs::write(&abs, &normalized).map_err(|source| Error::Io {
187            path: abs.clone(),
188            source,
189        })?;
190        Ok(FixOutcome::Applied(format!(
191            "normalized {} to {}",
192            path.display(),
193            self.target.name()
194        )))
195    }
196}
197
198fn normalize_line_endings(bytes: &[u8], target: LineEndingTarget) -> Vec<u8> {
199    let target_bytes = target.bytes();
200    let mut out = Vec::with_capacity(bytes.len());
201    let mut i = 0;
202    while i < bytes.len() {
203        if bytes[i] == b'\n' {
204            // Drop a preceding CR so `\r\n` collapses to `\n` before
205            // we emit the target.
206            if out.last().copied() == Some(b'\r') {
207                out.pop();
208            }
209            out.extend_from_slice(target_bytes);
210        } else {
211            out.push(bytes[i]);
212        }
213        i += 1;
214    }
215    out
216}
217
218/// Collapses runs of blank lines longer than `max` down to exactly
219/// `max` blank lines. A blank line is one whose content between
220/// line endings is empty or only spaces/tabs. Preserves the file's
221/// line endings (LF vs. CRLF) by operating on byte-level newlines.
222#[derive(Debug)]
223pub struct FileCollapseBlankLinesFixer {
224    max: u32,
225}
226
227impl FileCollapseBlankLinesFixer {
228    pub fn new(max: u32) -> Self {
229        Self { max }
230    }
231}
232
233impl Fixer for FileCollapseBlankLinesFixer {
234    fn describe(&self) -> String {
235        format!("collapse runs of blank lines to at most {}", self.max)
236    }
237
238    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome> {
239        let Some(path) = &violation.path else {
240            return Ok(FixOutcome::Skipped(
241                "violation did not carry a path".to_string(),
242            ));
243        };
244        let abs = ctx.root.join(path);
245        if ctx.dry_run {
246            return Ok(FixOutcome::Applied(format!(
247                "would collapse blank lines in {} to at most {}",
248                path.display(),
249                self.max,
250            )));
251        }
252        let existing = match alint_core::read_for_fix(&abs, path, ctx)? {
253            alint_core::ReadForFix::Bytes(b) => b,
254            alint_core::ReadForFix::Skipped(outcome) => return Ok(outcome),
255        };
256        let Ok(text) = std::str::from_utf8(&existing) else {
257            return Ok(FixOutcome::Skipped(format!(
258                "{} is not UTF-8; cannot collapse",
259                path.display()
260            )));
261        };
262        let collapsed = collapse_blank_lines(text, self.max);
263        if collapsed.as_bytes() == existing {
264            return Ok(FixOutcome::Skipped(format!(
265                "{} already clean",
266                path.display()
267            )));
268        }
269        std::fs::write(&abs, collapsed.as_bytes()).map_err(|source| Error::Io {
270            path: abs.clone(),
271            source,
272        })?;
273        Ok(FixOutcome::Applied(format!(
274            "collapsed blank-line runs in {} to at most {}",
275            path.display(),
276            self.max,
277        )))
278    }
279}
280
281/// A "blank" line has content consisting only of spaces or tabs.
282pub(crate) fn line_is_blank(body: &str) -> bool {
283    body.bytes().all(|b| b == b' ' || b == b'\t')
284}
285
286/// Walk the file in (body, ending) pairs so the final slot after the
287/// last newline doesn't get double-counted as an extra blank line.
288/// Preserves CRLF vs LF verbatim.
289pub(crate) fn collapse_blank_lines(text: &str, max: u32) -> String {
290    let mut out = String::with_capacity(text.len());
291    let mut blank_run: u32 = 0;
292    let mut remaining = text;
293    loop {
294        let (body, ending, rest) = match remaining.find('\n') {
295            Some(i) => {
296                let before = &remaining[..i];
297                let (body, cr) = match before.strip_suffix('\r') {
298                    Some(s) => (s, "\r\n"),
299                    None => (before, "\n"),
300                };
301                (body, cr, &remaining[i + 1..])
302            }
303            None => (remaining, "", ""),
304        };
305        let blank = line_is_blank(body);
306        if blank {
307            blank_run += 1;
308            if blank_run > max {
309                if ending.is_empty() {
310                    break;
311                }
312                remaining = rest;
313                continue;
314            }
315        } else {
316            blank_run = 0;
317        }
318        out.push_str(body);
319        out.push_str(ending);
320        if ending.is_empty() {
321            break;
322        }
323        remaining = rest;
324    }
325    out
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use tempfile::TempDir;
332
333    fn make_ctx(tmp: &TempDir, dry_run: bool) -> FixContext<'_> {
334        FixContext {
335            root: tmp.path(),
336            dry_run,
337            fix_size_limit: None,
338        }
339    }
340
341    #[test]
342    fn strip_trailing_whitespace_preserves_lf_and_crlf() {
343        assert_eq!(strip_trailing_whitespace("a  \nb\t\n"), "a\nb\n");
344        assert_eq!(strip_trailing_whitespace("a  \r\nb\t\r\n"), "a\r\nb\r\n");
345    }
346
347    #[test]
348    fn file_trim_trailing_whitespace_rewrites_in_place() {
349        let tmp = TempDir::new().unwrap();
350        std::fs::write(tmp.path().join("x.rs"), "let _ = 1;   \n").unwrap();
351        let outcome = FileTrimTrailingWhitespaceFixer
352            .apply(
353                &Violation::new("ws").with_path(std::path::Path::new("x.rs")),
354                &make_ctx(&tmp, false),
355            )
356            .unwrap();
357        assert!(matches!(outcome, FixOutcome::Applied(_)));
358        assert_eq!(
359            std::fs::read_to_string(tmp.path().join("x.rs")).unwrap(),
360            "let _ = 1;\n"
361        );
362    }
363
364    #[test]
365    fn file_trim_trailing_whitespace_honors_size_limit() {
366        let tmp = TempDir::new().unwrap();
367        let big = "x   \n".repeat(2_000);
368        std::fs::write(tmp.path().join("big.txt"), &big).unwrap();
369        let ctx = FixContext {
370            root: tmp.path(),
371            dry_run: false,
372            fix_size_limit: Some(100),
373        };
374        let outcome = FileTrimTrailingWhitespaceFixer
375            .apply(
376                &Violation::new("ws").with_path(std::path::Path::new("big.txt")),
377                &ctx,
378            )
379            .unwrap();
380        match outcome {
381            FixOutcome::Skipped(reason) => {
382                assert!(reason.contains("fix_size_limit"), "{reason}");
383            }
384            FixOutcome::Applied(_) => panic!("expected Skipped on oversized file"),
385        }
386        // Disk unchanged.
387        assert_eq!(
388            std::fs::read_to_string(tmp.path().join("big.txt")).unwrap(),
389            big
390        );
391    }
392
393    #[test]
394    fn file_append_final_newline_adds_missing_newline() {
395        let tmp = TempDir::new().unwrap();
396        std::fs::write(tmp.path().join("x.txt"), "hello").unwrap();
397        FileAppendFinalNewlineFixer
398            .apply(
399                &Violation::new("eof").with_path(std::path::Path::new("x.txt")),
400                &make_ctx(&tmp, false),
401            )
402            .unwrap();
403        assert_eq!(
404            std::fs::read_to_string(tmp.path().join("x.txt")).unwrap(),
405            "hello\n"
406        );
407    }
408
409    #[test]
410    fn normalize_line_endings_lf_target() {
411        let mixed = b"a\r\nb\nc\r\nd".to_vec();
412        let out = normalize_line_endings(&mixed, LineEndingTarget::Lf);
413        assert_eq!(out, b"a\nb\nc\nd");
414    }
415
416    #[test]
417    fn normalize_line_endings_crlf_target() {
418        let mixed = b"a\r\nb\nc\r\nd".to_vec();
419        let out = normalize_line_endings(&mixed, LineEndingTarget::Crlf);
420        assert_eq!(out, b"a\r\nb\r\nc\r\nd");
421    }
422
423    #[test]
424    fn file_normalize_line_endings_rewrites_to_lf() {
425        let tmp = TempDir::new().unwrap();
426        std::fs::write(tmp.path().join("a.md"), "one\r\ntwo\r\n").unwrap();
427        FileNormalizeLineEndingsFixer::new(LineEndingTarget::Lf)
428            .apply(
429                &Violation::new("le").with_path(std::path::Path::new("a.md")),
430                &make_ctx(&tmp, false),
431            )
432            .unwrap();
433        assert_eq!(
434            std::fs::read_to_string(tmp.path().join("a.md")).unwrap(),
435            "one\ntwo\n"
436        );
437    }
438
439    #[test]
440    fn collapse_blank_lines_keeps_up_to_max() {
441        assert_eq!(collapse_blank_lines("a\n\n\nb\n", 1), "a\n\nb\n");
442        assert_eq!(collapse_blank_lines("a\n\n\n\nb\n", 2), "a\n\n\nb\n");
443        assert_eq!(collapse_blank_lines("a\nb\n", 1), "a\nb\n");
444    }
445
446    #[test]
447    fn collapse_blank_lines_preserves_trailing_newline() {
448        // One existing blank line, max=1 → file must still end with "\n\n"
449        // (i.e. the blank line plus the EOF newline).
450        assert_eq!(collapse_blank_lines("a\n\n", 1), "a\n\n");
451    }
452
453    #[test]
454    fn collapse_blank_lines_max_zero_drops_all_blanks() {
455        assert_eq!(collapse_blank_lines("a\n\n\nb\n", 0), "a\nb\n");
456        assert_eq!(collapse_blank_lines("\n", 0), "");
457        assert_eq!(collapse_blank_lines("a\n\n", 0), "a\n");
458    }
459
460    #[test]
461    fn collapse_blank_lines_preserves_crlf() {
462        assert_eq!(
463            collapse_blank_lines("a\r\n\r\n\r\n\r\nb\r\n", 1),
464            "a\r\n\r\nb\r\n"
465        );
466    }
467
468    #[test]
469    fn collapse_blank_lines_treats_whitespace_only_as_blank() {
470        // Lines with only spaces/tabs count as blank, and dropped
471        // copies disappear entirely (their whitespace goes too).
472        assert_eq!(collapse_blank_lines("a\n  \n\t\n\nb\n", 1), "a\n  \nb\n");
473    }
474
475    #[test]
476    fn collapse_blank_lines_no_op_on_empty_file() {
477        assert_eq!(collapse_blank_lines("", 2), "");
478    }
479}