Skip to main content

rubyfast/
fix.rs

1use std::path::Path;
2
3use lib_ruby_parser::{ErrorLevel, Parser};
4
5/// A single byte-range replacement in a source file.
6#[derive(Debug, Clone)]
7pub struct Replacement {
8    pub start: usize,
9    pub end: usize,
10    pub text: String,
11}
12
13/// A fix consisting of one or more replacements to apply atomically.
14#[derive(Debug, Clone)]
15pub struct Fix {
16    pub replacements: Vec<Replacement>,
17}
18
19impl Fix {
20    pub fn single(start: usize, end: usize, text: impl Into<String>) -> Self {
21        Self {
22            replacements: vec![Replacement {
23                start,
24                end,
25                text: text.into(),
26            }],
27        }
28    }
29
30    pub fn two(
31        start1: usize,
32        end1: usize,
33        text1: impl Into<String>,
34        start2: usize,
35        end2: usize,
36        text2: impl Into<String>,
37    ) -> Self {
38        Self {
39            replacements: vec![
40                Replacement {
41                    start: start1,
42                    end: end1,
43                    text: text1.into(),
44                },
45                Replacement {
46                    start: start2,
47                    end: end2,
48                    text: text2.into(),
49                },
50            ],
51        }
52    }
53}
54
55/// Apply a set of fixes to source bytes. Returns the fixed source.
56/// Fixes are applied in reverse byte order to preserve offsets.
57/// Overlapping replacements are skipped.
58pub fn apply_fixes(source: &[u8], fixes: &[Fix]) -> Vec<u8> {
59    // Flatten all replacements and sort by start descending
60    let mut replacements: Vec<&Replacement> = fixes.iter().flat_map(|f| &f.replacements).collect();
61
62    replacements.sort_by(|a, b| b.start.cmp(&a.start));
63
64    let mut result = source.to_vec();
65    let mut last_start = usize::MAX;
66
67    for r in &replacements {
68        // Skip overlapping or out-of-bounds replacements
69        if r.end > last_start || r.start > result.len() || r.end > result.len() {
70            continue;
71        }
72        result.splice(r.start..r.end, r.text.bytes());
73        last_start = r.start;
74    }
75
76    result
77}
78
79/// Verify that the given source parses without fatal errors.
80pub fn verify_syntax(source: &[u8]) -> bool {
81    let result = Parser::new(source.to_vec(), Default::default()).do_parse();
82    !result
83        .diagnostics
84        .iter()
85        .any(|d| d.level == ErrorLevel::Error)
86}
87
88/// Apply fixes to a file: read -> fix -> verify syntax -> write.
89/// Returns the number of fixes applied, or an error.
90pub fn apply_fixes_to_file(path: &Path, fixes: &[Fix]) -> Result<usize, String> {
91    if fixes.is_empty() {
92        return Ok(0);
93    }
94
95    let source =
96        std::fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
97    let fixed = apply_fixes(&source, fixes);
98
99    if !verify_syntax(&fixed) {
100        return Err(format!(
101            "Fix would produce invalid syntax in {}; skipping",
102            path.display()
103        ));
104    }
105
106    std::fs::write(path, &fixed)
107        .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
108
109    Ok(fixes.len())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn single_replacement() {
118        let source = b"hello world";
119        let fix = Fix::single(6, 11, "rust");
120        let result = apply_fixes(source, &[fix]);
121        assert_eq!(result, b"hello rust");
122    }
123
124    #[test]
125    fn multiple_non_overlapping() {
126        let source = b"foo.bar.baz";
127        let fixes = vec![Fix::single(0, 3, "qux"), Fix::single(8, 11, "quux")];
128        let result = apply_fixes(source, &fixes);
129        assert_eq!(result, b"qux.bar.quux");
130    }
131
132    #[test]
133    fn overlapping_skipped() {
134        let source = b"abcdefgh";
135        let fixes = vec![
136            Fix::single(2, 6, "XX"), // replace cdef with XX
137            Fix::single(4, 8, "YY"), // overlaps — should be skipped
138        ];
139        // Because we sort descending, 4..8 is processed first, then 2..6 overlaps
140        // Actually: sorted descending by start: 4..8 first (start=4), then 2..6 (start=2)
141        // 4..8 replaces "efgh" -> "YY", result = "abcdYY", last_start=4
142        // 2..6 has end=6 > last_start=4, so it's skipped
143        let result = apply_fixes(source, &fixes);
144        assert_eq!(result, b"abcdYY");
145    }
146
147    #[test]
148    fn verify_valid_ruby() {
149        assert!(verify_syntax(b"x = 1 + 2"));
150    }
151
152    #[test]
153    fn verify_invalid_ruby() {
154        assert!(!verify_syntax(b"def def def"));
155    }
156
157    #[test]
158    fn two_replacements_in_one_fix() {
159        let source = b"arr.map { |x| [x] }.flatten(1)";
160        // Rename .map -> .flat_map and delete .flatten(1)
161        let fix = Fix::two(
162            4, 7, "flat_map", // "map" -> "flat_map"
163            19, 30, "", // delete ".flatten(1)"
164        );
165        let result = apply_fixes(source, &[fix]);
166        assert_eq!(result, b"arr.flat_map { |x| [x] }");
167    }
168
169    #[test]
170    fn apply_fixes_empty_fixes() {
171        let source = b"hello world";
172        let result = apply_fixes(source, &[]);
173        assert_eq!(result, source);
174    }
175
176    #[test]
177    fn apply_fixes_out_of_bounds_skipped() {
178        let source = b"short";
179        let fix = Fix::single(10, 20, "big");
180        let result = apply_fixes(source, &[fix]);
181        assert_eq!(result, b"short");
182    }
183
184    #[test]
185    fn apply_fixes_to_file_no_fixes() {
186        let dir = tempfile::TempDir::new().unwrap();
187        let file = dir.path().join("test.rb");
188        std::fs::write(&file, "x = 1").unwrap();
189        let result = apply_fixes_to_file(&file, &[]).unwrap();
190        assert_eq!(result, 0);
191    }
192
193    #[test]
194    fn apply_fixes_to_file_valid_fix() {
195        let dir = tempfile::TempDir::new().unwrap();
196        let file = dir.path().join("test.rb");
197        std::fs::write(&file, "for x in [1]; end").unwrap();
198        // Replace "for x in [1]; " with "[1].each do |x|; "
199        let fix = Fix::single(0, 14, "[1].each do |x|;");
200        let result = apply_fixes_to_file(&file, &[fix]).unwrap();
201        assert_eq!(result, 1);
202    }
203
204    #[test]
205    fn apply_fixes_to_file_syntax_error_skipped() {
206        let dir = tempfile::TempDir::new().unwrap();
207        let file = dir.path().join("test.rb");
208        std::fs::write(&file, "x = 1 + 2").unwrap();
209        // This fix produces invalid syntax
210        let fix = Fix::single(0, 9, "def def def");
211        let result = apply_fixes_to_file(&file, &[fix]);
212        assert!(result.is_err());
213    }
214
215    #[test]
216    fn apply_fixes_to_file_nonexistent_file() {
217        let fix = Fix::single(0, 3, "x");
218        let result = apply_fixes_to_file(Path::new("/nonexistent.rb"), &[fix]);
219        assert!(result.is_err());
220    }
221}