1use std::path::PathBuf;
7
8use clap::Args;
9
10pub mod rewriter;
11pub mod rules;
12pub mod walker;
13
14#[derive(Args, Debug)]
16pub struct MigrateV2Args {
17 #[arg(default_value = ".")]
19 pub path: PathBuf,
20
21 #[arg(long)]
23 pub dry_run: bool,
24
25 #[arg(long, value_delimiter = ',')]
27 pub skip: Vec<String>,
28}
29
30pub fn run(args: MigrateV2Args) -> anyhow::Result<()> {
40 let all_rules = rules::all();
41 let known_rule_names: std::collections::BTreeSet<&'static str> =
42 all_rules.iter().map(|r| r.name()).collect();
43 let unknown: Vec<&str> = args
44 .skip
45 .iter()
46 .map(String::as_str)
47 .filter(|name| !known_rule_names.contains(name))
48 .collect();
49 if !unknown.is_empty() {
50 anyhow::bail!("unknown --skip rule(s): {}", unknown.join(", "));
51 }
52 let rules: Vec<_> = all_rules
53 .into_iter()
54 .filter(|r| !args.skip.iter().any(|s| s == r.name()))
55 .collect();
56
57 let files = walker::find_rs_files(&args.path)?;
58 let mut changed = 0_usize;
59
60 for path in files {
61 let src = read_developer_file(&path)?;
62 let parsed: syn::File = match syn::parse_file(&src) {
63 Ok(f) => f,
64 Err(_) => continue,
66 };
67 let mut out_ast = parsed.clone();
68 for r in &rules {
69 out_ast = r.rewrite(out_ast);
70 }
71
72 let out = apply_changes_preserving_formatting(&src, &parsed, &out_ast);
73 if out != src {
74 changed += 1;
75 if args.dry_run {
76 println!("would rewrite: {}", path.display());
77 } else {
78 write_developer_file(&path, &out)?;
79 println!("rewrote: {}", path.display());
80 }
81 }
82 }
83
84 println!(
85 "\nDone. {} file(s) {}.",
86 changed,
87 if args.dry_run {
88 "would change"
89 } else {
90 "changed"
91 }
92 );
93 Ok(())
94}
95
96fn apply_changes_preserving_formatting(
104 src: &str,
105 parsed: &syn::File,
106 out_ast: &syn::File,
107) -> String {
108 if parsed.items.len() != out_ast.items.len() {
109 return prettyplease::unparse(out_ast);
110 }
111
112 let mut result = String::with_capacity(src.len() + 1024);
113 let mut last_pos: usize = 0;
114
115 let item_count = parsed.items.len();
116 for i in 0..item_count {
117 let orig_item = &parsed.items[i];
118 let new_item = &out_ast.items[i];
119
120 let formatted_orig = format_single_item(orig_item);
121 let formatted_new = format_single_item(new_item);
122
123 let (start_byte, end_byte) = find_item_in_source(src, last_pos, &formatted_orig);
124 if start_byte < last_pos || start_byte >= end_byte || end_byte > src.len() {
125 return prettyplease::unparse(out_ast);
126 }
127
128 result.push_str(&src[last_pos..start_byte]);
130
131 if formatted_orig == formatted_new {
132 result.push_str(&src[start_byte..end_byte]);
134 } else {
135 result.push_str(&format_single_item(new_item));
137 }
138
139 last_pos = end_byte;
140 }
141
142 if last_pos < src.len() {
144 result.push_str(&src[last_pos..]);
145 }
146
147 result
148}
149
150fn format_single_item(item: &syn::Item) -> String {
155 let file = syn::File {
156 shebang: None,
157 attrs: vec![],
158 items: vec![item.clone()],
159 };
160 let formatted = prettyplease::unparse(&file);
161 formatted.trim_end().to_string()
162}
163
164fn find_item_in_source(src: &str, search_from: usize, item_tokens: &str) -> (usize, usize) {
168 let anchor = item_tokens
169 .lines()
170 .find(|l| {
171 let trimmed = l.trim();
172 !trimmed.is_empty()
173 && !trimmed.starts_with("//")
174 && !trimmed.starts_with("#[")
175 && !trimmed.starts_with("///")
176 })
177 .unwrap_or("");
178
179 if anchor.is_empty() {
180 return (search_from, search_from);
181 }
182
183 let rest = &src[search_from..];
184 let start = match rest.find(anchor) {
185 Some(pos) => search_from + pos,
186 None => return (search_from, search_from),
187 };
188 let start = find_item_start_with_prefix(src, search_from, start);
189
190 let after_start = &src[start..];
191 let end_offset = find_item_end_offset(after_start);
192 (start, start + end_offset)
193}
194
195fn find_item_start_with_prefix(src: &str, search_from: usize, item_start: usize) -> usize {
196 let mut start = line_start(src, item_start);
197 while start > search_from {
198 let previous_end = start.saturating_sub(1);
199 let previous_start = line_start(src, previous_end);
200 let line = &src[previous_start..start];
201 let trimmed = line.trim();
202 if trimmed.starts_with("#[") || trimmed.starts_with("///") {
203 start = previous_start;
204 } else {
205 break;
206 }
207 }
208 start
209}
210
211fn line_start(src: &str, pos: usize) -> usize {
212 src[..pos].rfind('\n').map_or(0, |idx| idx + 1)
213}
214
215fn find_item_end_offset(src: &str) -> usize {
221 let mut brace_depth: i32 = 0;
222 let mut has_block = false;
223 let bytes = src.as_bytes();
224 let len = bytes.len();
225 let mut i = 0;
226
227 while i < len {
228 let ch = bytes[i];
229
230 if ch == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
232 while i < len && bytes[i] != b'\n' {
233 i += 1;
234 }
235 continue;
236 }
237
238 if ch == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
240 i += 2;
241 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
242 i += 1;
243 }
244 if i + 1 < len {
245 i += 2; }
247 continue;
248 }
249
250 if ch == b'r' && i + 1 < len {
252 let next = bytes[i + 1];
253 if next == b'"' || next == b'#' {
254 let hash_count = if next == b'"' {
255 0
256 } else {
257 let mut count = 0;
258 let mut j = i + 1;
259 while j < len && bytes[j] == b'#' {
260 count += 1;
261 j += 1;
262 }
263 if j < len && bytes[j] == b'"' {
264 i = j; count
266 } else {
267 i += 1;
268 continue;
269 }
270 };
271 i += 1; while i < len {
273 if bytes[i] == b'"' {
274 let mut h = 0;
276 let mut j = i + 1;
277 while j < len && bytes[j] == b'#' && h < hash_count {
278 h += 1;
279 j += 1;
280 }
281 if h == hash_count {
282 i = j;
283 break;
284 }
285 }
286 if bytes[i] == b'\\' && i + 1 < len {
287 i += 2; } else {
289 i += 1;
290 }
291 }
292 continue;
293 }
294 }
295
296 if ch == b'"' {
298 i += 1;
299 while i < len {
300 if bytes[i] == b'"' {
301 i += 1;
302 break;
303 }
304 if bytes[i] == b'\\' && i + 1 < len {
305 i += 2; } else {
307 i += 1;
308 }
309 }
310 continue;
311 }
312
313 if ch == b'b' && i + 1 < len && bytes[i + 1] == b'\'' {
315 i += 2; while i < len {
317 if bytes[i] == b'\'' {
318 i += 1;
319 break;
320 }
321 if bytes[i] == b'\\' && i + 1 < len {
322 i += 2;
323 } else {
324 i += 1;
325 }
326 }
327 continue;
328 }
329
330 if ch == b'\'' {
332 i += 1; if i < len {
334 if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' {
336 while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
337 i += 1;
338 }
339 } else {
340 if bytes[i] == b'\\' && i + 1 < len {
342 i += 2;
343 } else {
344 i += 1;
345 }
346 if i < len && bytes[i] == b'\'' {
348 i += 1;
349 }
350 }
351 }
352 continue;
353 }
354
355 match ch {
356 b'{' => {
357 brace_depth += 1;
358 has_block = true;
359 }
360 b'}' => {
361 brace_depth -= 1;
362 if brace_depth == 0 && has_block {
363 return i + 1;
364 }
365 }
366 b';' if brace_depth == 0 => {
367 return i + 1;
368 }
369 _ => {}
370 }
371 i += 1;
372 }
373
374 src.len()
375}
376
377fn read_developer_file(path: &std::path::Path) -> anyhow::Result<String> {
383 let canonical = path.canonicalize()?;
384 let mut file = std::fs::File::open(canonical)?; let mut buf = String::new();
386 std::io::Read::read_to_string(&mut file, &mut buf)?;
387 Ok(buf)
388}
389
390fn write_developer_file(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
397 let canonical = path.canonicalize()?;
398 let parent = canonical
399 .parent()
400 .ok_or_else(|| anyhow::anyhow!("no parent directory for {}", canonical.display()))?;
401 let file_name = canonical
402 .file_name()
403 .and_then(|n| n.to_str())
404 .unwrap_or("rewrite");
405 let random_suffix: u32 = {
406 use std::time::{SystemTime, UNIX_EPOCH};
407 let nanos = SystemTime::now()
408 .duration_since(UNIX_EPOCH)
409 .unwrap_or_default()
410 .subsec_nanos();
411 nanos ^ std::process::id()
412 };
413 let tmp = parent.join(format!(".{file_name}.{random_suffix:x}.tmp")); if let Err(e) = std::fs::write(&tmp, content) {
415 let _ = std::fs::remove_file(&tmp);
416 return Err(e.into());
417 }
418 if let Err(e) = std::fs::rename(&tmp, canonical) {
419 let _ = std::fs::remove_file(&tmp);
420 return Err(e.into());
421 }
422 Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use rstest::rstest;
429
430 #[rstest]
432 fn no_changes_output_identical() {
433 let src = "//! Module doc comment.\n\nuse std::collections::HashMap;\n\n/// A struct.\npub struct Foo {\n x: i32,\n}\n";
435 let parsed: syn::File = syn::parse_file(src).unwrap();
436 let out_ast = parsed.clone();
437
438 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
440
441 assert_eq!(result, src);
443 }
444
445 #[rstest]
447 fn comments_between_items_preserved() {
448 let src = "//! Module doc.\n\n// Comment before struct\npub struct Foo {\n x: i32,\n}\n\n// Comment between items\npub struct Bar {\n y: String,\n}\n";
450 let parsed: syn::File = syn::parse_file(src).unwrap();
451 let mut out_ast = parsed.clone();
452
453 if let syn::Item::Struct(s) = &mut out_ast.items[0] {
455 s.ident = syn::Ident::new("Foo2", s.ident.span());
456 }
457
458 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
460
461 assert!(
463 result.contains("pub struct Foo2"),
464 "changed item not updated"
465 );
466 assert!(result.contains("//! Module doc."), "module doc lost");
467 assert!(
468 result.contains("// Comment before struct"),
469 "comment before struct lost"
470 );
471 assert!(
472 result.contains("// Comment between items"),
473 "inter-item comment lost"
474 );
475 assert!(result.contains("pub struct Bar"), "unchanged item lost");
476 }
477
478 #[rstest]
480 fn blank_lines_between_items_preserved() {
481 let src = "use std::io;\n\n\nuse std::fs;\n\n\n\nuse std::path;\n";
483 let parsed: syn::File = syn::parse_file(src).unwrap();
484 let mut out_ast = parsed.clone();
485
486 if let syn::Item::Use(u) = &mut out_ast.items[1] {
488 *u = syn::parse_quote!(
490 use std::fs::File;
491 );
492 }
493
494 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
496
497 assert!(result.contains("use std::io;"), "first use lost");
499 assert!(
500 result.contains("use std::fs::File;"),
501 "changed use not updated"
502 );
503 assert!(result.contains("use std::path;"), "third use lost");
504 assert!(
506 result.contains("use std::io;\n\n\n"),
507 "blank lines after first use altered"
508 );
509 assert!(
510 result.contains("\n\n\nuse std::path;"),
511 "blank lines before third use altered"
512 );
513 }
514
515 #[rstest]
517 fn module_doc_comment_preserved() {
518 let src = "//! Crate-level documentation.\n//! Second line.\n\npub fn foo() {}\n";
520 let parsed: syn::File = syn::parse_file(src).unwrap();
521 let mut out_ast = parsed.clone();
522
523 if let syn::Item::Fn(f) = &mut out_ast.items[0] {
525 f.sig.ident = syn::Ident::new("bar", f.sig.ident.span());
526 }
527
528 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
530
531 assert!(
533 result.contains("//! Crate-level documentation."),
534 "module doc lost"
535 );
536 assert!(result.contains("//! Second line."), "second doc line lost");
537 assert!(result.contains("pub fn bar"), "renamed function missing");
538 }
539
540 #[rstest]
543 fn only_changed_item_replaced() {
544 let src = "pub const A: i32 = 1;\npub const B: i32 = 2;\npub const C: i32 = 3;\n";
546 let parsed: syn::File = syn::parse_file(src).unwrap();
547 let mut out_ast = parsed.clone();
548
549 if let syn::Item::Const(c) = &mut out_ast.items[1] {
551 c.ident = syn::Ident::new("B_CHANGED", c.ident.span());
552 }
553
554 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
556
557 assert!(
559 result.contains("pub const A: i32 = 1;"),
560 "first item altered"
561 );
562 assert!(
563 result.contains("pub const B_CHANGED"),
564 "changed item not updated"
565 );
566 assert!(
567 result.contains("pub const C: i32 = 3;"),
568 "third item altered"
569 );
570 let a_idx = result.find("pub const A").unwrap();
572 let b_idx = result.find("pub const B_CHANGED").unwrap();
573 let c_idx = result.find("pub const C").unwrap();
574 assert!(a_idx < b_idx && b_idx < c_idx, "item order changed");
575 }
576
577 #[rstest]
578 fn item_count_mismatch_falls_back_to_full_unparse() {
579 let src = "pub fn a() {}\n";
581 let parsed: syn::File = syn::parse_file(src).unwrap();
582 let mut out_ast = parsed.clone();
583 out_ast.items.push(syn::parse_quote!(
584 pub fn b() {}
585 ));
586
587 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
589
590 assert_eq!(result, prettyplease::unparse(&out_ast));
592 }
593
594 #[rstest]
595 fn missing_item_mapping_falls_back_to_full_unparse() {
596 let parsed_src = "pub fn original() {}\n";
598 let src = "pub fn different() {}\n";
599 let parsed: syn::File = syn::parse_file(parsed_src).unwrap();
600 let out_ast = parsed.clone();
601
602 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
604
605 assert_eq!(result, prettyplease::unparse(&out_ast));
607 }
608}