1use std::io::Write;
2
3use alint_core::{Error, FixContext, FixOutcome, Fixer, Result, Violation};
4
5#[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 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#[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#[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#[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 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#[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
281pub(crate) fn line_is_blank(body: &str) -> bool {
283 body.bytes().all(|b| b == b' ' || b == b'\t')
284}
285
286pub(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 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 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 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}