1use std::collections::BTreeMap;
2
3use crate::{
4 atomic,
5 checksum::{self, Checksum},
6 lock::{Lock, LockError},
7 path::{Path, PathBuf},
8};
9
10pub(crate) type SourceSnapshots = BTreeMap<String, Checksum>;
11
12#[derive(Debug, thiserror::Error)]
13pub(crate) enum ApplyError {
14 #[error(transparent)]
15 Filesystem(#[from] crate::error::Error),
16 #[error(transparent)]
17 Lock(#[from] LockError),
18 #[error("fix plan for {0} has no source snapshot")]
19 MissingSnapshot(PathBuf),
20 #[error("{0} changed after it was analyzed; no fixes were applied")]
21 StaleSource(PathBuf),
22 #[error(
23 "invalid fix range {start_line}..={end_line} for {path} ({line_count} source line(s)); no fixes were applied"
24 )]
25 InvalidRange {
26 path: PathBuf,
27 start_line: usize,
28 end_line: usize,
29 line_count: usize,
30 },
31 #[error("{0} contains mixed or unsupported line endings; no fixes were applied")]
32 MixedLineEndings(PathBuf),
33 #[error("fixes would make {path} invalid {language}: {message}; no fixes were applied")]
34 InvalidSyntax {
35 path: PathBuf,
36 language: &'static str,
37 message: String,
38 },
39}
40
41#[derive(Clone, Debug)]
43pub struct Fix {
44 pub start_line: usize,
45 pub end_line: usize,
46 pub replacement: String,
47}
48
49#[derive(Clone, Debug)]
51pub(crate) struct TreeFix {
52 pub(crate) rel: String,
53 pub(crate) rule: &'static str,
54 pub(crate) replacement: String,
55}
56
57impl Fix {
58 pub fn replace_line(line: usize, new: impl Into<String>) -> Self {
59 Fix {
60 start_line: line,
61 end_line: line,
62 replacement: new.into(),
63 }
64 }
65
66 pub fn replace_lines(
67 start_line: usize,
68 end_line: usize,
69 replacement: impl Into<String>,
70 ) -> Self {
71 Fix {
72 start_line,
73 end_line,
74 replacement: replacement.into(),
75 }
76 }
77
78 #[must_use]
79 pub fn delete(start_line: usize, end_line: usize) -> Self {
80 Fix {
81 start_line,
82 end_line,
83 replacement: String::new(),
84 }
85 }
86}
87
88pub(crate) fn apply_fixes(
90 fixes: &[(String, Fix)],
91 snapshots: &SourceSnapshots,
92 root: &Path,
93) -> Result<usize, ApplyError> {
94 let mut by_file: BTreeMap<&str, Vec<&Fix>> = BTreeMap::new();
95
96 for (rel, fix) in fixes {
97 by_file.entry(rel).or_default().push(fix);
98 }
99
100 let lock_path = root.join(".rulewright.lock");
101 let _lock = Lock::try_acquire(&lock_path)?;
102 let sources = verified_sources(by_file.keys().copied(), snapshots, root)?;
103
104 validate_line_fix_plan(&by_file, &sources, root)?;
105
106 let mut total = 0;
107 let mut replacements = Vec::new();
108
109 for (rel, mut file_fixes) in by_file {
110 let path = root.join(rel);
111 let Some(contents) = sources.get(rel) else {
112 return Err(ApplyError::MissingSnapshot(path));
113 };
114 let mut lines: Vec<String> = contents.lines().map(String::from).collect();
115 let mut file_total = 0;
116
117 file_fixes.sort_by_key(|a| std::cmp::Reverse(a.start_line));
118
119 let mut lowest_touched = usize::MAX;
121
122 for fix in file_fixes {
123 let start = fix.start_line - 1;
124 let end = fix.end_line;
125
126 if end > lowest_touched {
127 continue;
128 }
129
130 if fix.replacement.is_empty() {
131 lines.drain(start..end);
132 } else {
133 let new_lines: Vec<String> = fix.replacement.lines().map(String::from).collect();
134
135 lines.splice(start..end, new_lines);
136 }
137
138 lowest_touched = start;
139 file_total += 1;
140 }
141
142 if file_total == 0 {
143 continue;
144 }
145
146 let newline = line_ending(contents, &path)?;
147 let mut output = lines.join(newline);
148
149 if contents.ends_with('\n') {
150 output.push_str(newline);
151 }
152
153 validate_syntax(&path, contents, &output)?;
154 replacements.push((path, output));
155 total += file_total;
156 }
157
158 for (path, replacement) in replacements {
159 atomic::replace(&path, replacement.as_bytes())?;
160 }
161
162 Ok(total)
163}
164
165pub(crate) fn apply_tree_fixes(
167 fixes: &[TreeFix],
168 snapshots: &SourceSnapshots,
169 root: &Path,
170) -> Result<usize, ApplyError> {
171 let lock_path = root.join(".rulewright.lock");
172 let _lock = Lock::try_acquire(&lock_path)?;
173 let sources = verified_sources(fixes.iter().map(|fix| fix.rel.as_str()), snapshots, root)?;
174
175 for fix in fixes {
176 let path = root.join(&fix.rel);
177 let Some(contents) = sources.get(&fix.rel) else {
178 return Err(ApplyError::MissingSnapshot(path));
179 };
180
181 line_ending(contents, &path)?;
182 }
183
184 let mut replacements = Vec::with_capacity(fixes.len());
185
186 for fix in fixes {
187 let path = root.join(&fix.rel);
188 let Some(contents) = sources.get(&fix.rel) else {
189 return Err(ApplyError::MissingSnapshot(path));
190 };
191 let replacement = with_line_ending(&fix.replacement, line_ending(contents, &path)?);
192
193 validate_syntax(&path, contents, &replacement)?;
194 replacements.push((path, replacement));
195 }
196
197 for (path, replacement) in replacements {
198 atomic::replace(&path, replacement.as_bytes())?;
199 }
200
201 Ok(fixes.len())
202}
203
204fn validate_syntax(path: &Path, original: &str, replacement: &str) -> Result<(), ApplyError> {
205 match path.extension().and_then(|extension| extension.to_str()) {
206 Some("rs") => {
207 let original =
208 ra_ap_syntax::SourceFile::parse(original, ra_ap_syntax::Edition::Edition2024);
209
210 if !original.errors().is_empty() {
211 return Ok(());
212 }
213
214 let parsed =
215 ra_ap_syntax::SourceFile::parse(replacement, ra_ap_syntax::Edition::Edition2024);
216
217 if let Some(error) = parsed.errors().first() {
218 return Err(ApplyError::InvalidSyntax {
219 path: path.to_path_buf(),
220 language: "Rust",
221 message: error.to_string(),
222 });
223 }
224 }
225
226 Some("toml") => {
227 if toml::from_str::<toml::Value>(original).is_ok()
228 && let Err(error) = toml::from_str::<toml::Value>(replacement)
229 {
230 return Err(ApplyError::InvalidSyntax {
231 path: path.to_path_buf(),
232 language: "TOML",
233 message: error.to_string(),
234 });
235 }
236 }
237
238 _ => {}
239 }
240
241 Ok(())
242}
243
244fn verified_sources<'a>(
245 rels: impl Iterator<Item = &'a str>,
246 snapshots: &SourceSnapshots,
247 root: &Path,
248) -> Result<BTreeMap<String, String>, ApplyError> {
249 let mut sources = BTreeMap::new();
250
251 for rel in rels {
252 let path = root.join(rel);
253 let Some(expected) = snapshots.get(rel) else {
254 return Err(ApplyError::MissingSnapshot(path));
255 };
256 let contents = crate::file::read_text(&path)?;
257
258 if checksum::bytes(&contents) != *expected {
259 return Err(ApplyError::StaleSource(path));
260 }
261
262 sources.insert(rel.to_owned(), contents);
263 }
264
265 Ok(sources)
266}
267
268fn validate_line_fix_plan(
269 by_file: &BTreeMap<&str, Vec<&Fix>>,
270 sources: &BTreeMap<String, String>,
271 root: &Path,
272) -> Result<(), ApplyError> {
273 for (rel, fixes) in by_file {
274 let path = root.join(rel);
275 let Some(contents) = sources.get(*rel) else {
276 return Err(ApplyError::MissingSnapshot(path));
277 };
278 let line_count = contents.lines().count();
279
280 line_ending(contents, &path)?;
281
282 for fix in fixes {
283 if fix.start_line == 0 || fix.end_line < fix.start_line || fix.end_line > line_count {
284 return Err(ApplyError::InvalidRange {
285 path,
286 start_line: fix.start_line,
287 end_line: fix.end_line,
288 line_count,
289 });
290 }
291 }
292 }
293
294 Ok(())
295}
296
297fn line_ending(contents: &str, path: &Path) -> Result<&'static str, ApplyError> {
298 let bytes = contents.as_bytes();
299 let mut saw_lf = false;
300 let mut saw_crlf = false;
301 let mut index = 0;
302
303 while index < bytes.len() {
304 let Some(&byte) = bytes.get(index) else {
305 break;
306 };
307
308 match byte {
309 b'\r' if bytes.get(index + 1) == Some(&b'\n') => {
310 saw_crlf = true;
311 index += 2;
312 }
313
314 b'\r' => return Err(ApplyError::MixedLineEndings(path.to_path_buf())),
315
316 b'\n' => {
317 saw_lf = true;
318 index += 1;
319 }
320
321 _ => index += 1,
322 }
323 }
324
325 if saw_lf && saw_crlf {
326 Err(ApplyError::MixedLineEndings(path.to_path_buf()))
327 } else if saw_crlf {
328 Ok("\r\n")
329 } else {
330 Ok("\n")
331 }
332}
333
334fn with_line_ending(contents: &str, newline: &str) -> String {
335 let normalized = contents.replace("\r\n", "\n");
336
337 if newline == "\n" {
338 normalized
339 } else {
340 normalized.replace('\n', newline)
341 }
342}
343
344#[cfg(test)]
345#[path = "fix_validation_tests.rs"]
346mod validation_tests;
347
348#[cfg(test)]
349mod tests {
350 use googletest::prelude::*;
351
352 use super::*;
353 use crate::{file, temporary::Directory};
354
355 #[gtest]
356 fn line_fixes_preserve_crlf() -> Result<()> {
357 let directory = Directory::new().or_fail()?;
358 let path = directory.path().join("source.rs");
359 let original = "first\r\nsecond\r\n";
360
361 file::write_text(&path, original).or_fail()?;
362 let snapshots =
363 SourceSnapshots::from([("source.rs".to_string(), checksum::bytes(original))]);
364 let applied = apply_fixes(
365 &[("source.rs".to_string(), Fix::replace_line(2, "changed"))],
366 &snapshots,
367 directory.path(),
368 )
369 .or_fail()?;
370
371 verify_eq!(applied, 1)?;
372
373 verify_eq!(file::read_text(&path).or_fail()?, "first\r\nchanged\r\n")
374 }
375
376 #[gtest]
377 fn stale_source_aborts_before_any_file_is_changed() -> Result<()> {
378 let directory = Directory::new().or_fail()?;
379 let first = directory.path().join("first.rs");
380 let second = directory.path().join("second.rs");
381
382 file::write_text(&first, "current\n").or_fail()?;
383 file::write_text(&second, "current\n").or_fail()?;
384 let snapshots = SourceSnapshots::from([
385 ("first.rs".to_string(), checksum::bytes("stale\n")),
386 ("second.rs".to_string(), checksum::bytes("current\n")),
387 ]);
388 let error = apply_fixes(
389 &[
390 ("first.rs".to_string(), Fix::replace_line(1, "changed")),
391 ("second.rs".to_string(), Fix::replace_line(1, "changed")),
392 ],
393 &snapshots,
394 directory.path(),
395 )
396 .unwrap_err();
397
398 verify_that!(
399 error.to_string(),
400 contains_substring("changed after it was analyzed")
401 )?;
402 verify_eq!(file::read_text(&first).or_fail()?, "current\n")?;
403
404 verify_eq!(file::read_text(&second).or_fail()?, "current\n")
405 }
406
407 #[gtest]
408 fn lock_failure_is_reported_as_an_error() -> Result<()> {
409 let directory = Directory::new().or_fail()?;
410 let path = directory.path().join("source.rs");
411
412 file::write_text(&path, "old\n").or_fail()?;
413 let snapshots =
414 SourceSnapshots::from([("source.rs".to_string(), checksum::bytes("old\n"))]);
415 let _lock = Lock::try_acquire(&directory.path().join(".rulewright.lock")).or_fail()?;
416 let error = apply_fixes(
417 &[("source.rs".to_string(), Fix::replace_line(1, "new"))],
418 &snapshots,
419 directory.path(),
420 )
421 .unwrap_err();
422
423 verify_that!(error.to_string(), contains_substring("another process"))?;
424
425 verify_eq!(file::read_text(&path).or_fail()?, "old\n")
426 }
427
428 #[gtest]
429 fn malformed_range_aborts_before_any_file_is_changed() -> Result<()> {
430 let directory = Directory::new().or_fail()?;
431 let first = directory.path().join("first.rs");
432 let second = directory.path().join("second.rs");
433
434 file::write_text(&first, "first\n").or_fail()?;
435 file::write_text(&second, "second\n").or_fail()?;
436 let snapshots = SourceSnapshots::from([
437 ("first.rs".to_string(), checksum::bytes("first\n")),
438 ("second.rs".to_string(), checksum::bytes("second\n")),
439 ]);
440 let error = apply_fixes(
441 &[
442 ("first.rs".to_string(), Fix::replace_line(1, "changed")),
443 ("second.rs".to_string(), Fix::replace_lines(0, 1, "invalid")),
444 ],
445 &snapshots,
446 directory.path(),
447 )
448 .unwrap_err();
449
450 verify_that!(error.to_string(), contains_substring("invalid fix range"))?;
451 verify_eq!(file::read_text(&first).or_fail()?, "first\n")?;
452
453 verify_eq!(file::read_text(&second).or_fail()?, "second\n")
454 }
455
456 #[gtest]
457 fn line_fix_rejects_mixed_endings_without_mutation() -> Result<()> {
458 let directory = Directory::new().or_fail()?;
459 let path = directory.path().join("source.rs");
460 let original = "first\r\nsecond\n";
461
462 file::write_text(&path, original).or_fail()?;
463 let snapshots =
464 SourceSnapshots::from([("source.rs".to_string(), checksum::bytes(original))]);
465 let error = apply_fixes(
466 &[("source.rs".to_string(), Fix::replace_line(1, "changed"))],
467 &snapshots,
468 directory.path(),
469 )
470 .unwrap_err();
471
472 verify_that!(error.to_string(), contains_substring("mixed"))?;
473
474 verify_eq!(file::read_text(&path).or_fail()?, original)
475 }
476
477 #[gtest]
478 fn tree_fix_rejects_mixed_endings_without_mutation() -> Result<()> {
479 let directory = Directory::new().or_fail()?;
480 let path = directory.path().join("source.rs");
481 let original = "first\r\nsecond\n";
482
483 file::write_text(&path, original).or_fail()?;
484 let snapshots =
485 SourceSnapshots::from([("source.rs".to_string(), checksum::bytes(original))]);
486 let error = apply_tree_fixes(
487 &[TreeFix {
488 rel: "source.rs".to_string(),
489 rule: "fixture",
490 replacement: "changed\n".to_string(),
491 }],
492 &snapshots,
493 directory.path(),
494 )
495 .unwrap_err();
496
497 verify_that!(error.to_string(), contains_substring("mixed"))?;
498
499 verify_eq!(file::read_text(&path).or_fail()?, original)
500 }
501}