|
| 1 | +use std::fs; |
| 2 | +use std::process::ExitCode; |
| 3 | + |
| 4 | +use rsscript::{Diagnostic, FixEdit, analyze_source_with_interfaces, standard_package_interfaces}; |
| 5 | +use serde_json::json; |
| 6 | + |
| 7 | +use super::{print_usage, read_interface_sources, required_flag_value}; |
| 8 | + |
| 9 | +#[derive(Debug)] |
| 10 | +struct FixOptions<'a> { |
| 11 | + json: bool, |
| 12 | + write: bool, |
| 13 | + path: Option<&'a str>, |
| 14 | + interfaces: Vec<&'a str>, |
| 15 | +} |
| 16 | + |
| 17 | +fn parse_fix_args(args: &[String]) -> Result<FixOptions<'_>, String> { |
| 18 | + let mut json = false; |
| 19 | + let mut write = false; |
| 20 | + let mut path = None; |
| 21 | + let mut interfaces = Vec::new(); |
| 22 | + let mut index = 0; |
| 23 | + while let Some(arg) = args.get(index) { |
| 24 | + match arg.as_str() { |
| 25 | + "--json" => json = true, |
| 26 | + "--write" => write = true, |
| 27 | + "--interface" => { |
| 28 | + index += 1; |
| 29 | + interfaces.push(required_flag_value(args, index, "--interface")?); |
| 30 | + } |
| 31 | + other if other.starts_with("--") => { |
| 32 | + return Err(format!("unknown argument `{other}`.")); |
| 33 | + } |
| 34 | + other if path.is_none() => path = Some(other), |
| 35 | + other => return Err(format!("unexpected extra path `{other}`.")), |
| 36 | + } |
| 37 | + index += 1; |
| 38 | + } |
| 39 | + Ok(FixOptions { |
| 40 | + json, |
| 41 | + write, |
| 42 | + path, |
| 43 | + interfaces, |
| 44 | + }) |
| 45 | +} |
| 46 | + |
| 47 | +/// A concrete edit ready to apply, paired with the diagnostic code it resolves. |
| 48 | +struct PlannedEdit { |
| 49 | + code: String, |
| 50 | + title: String, |
| 51 | + edit: FixEdit, |
| 52 | +} |
| 53 | + |
| 54 | +fn plan_edits(diagnostics: &[Diagnostic]) -> Vec<PlannedEdit> { |
| 55 | + let mut planned = Vec::new(); |
| 56 | + for diagnostic in diagnostics { |
| 57 | + for fix in &diagnostic.fixes { |
| 58 | + if fix.applicability != "machine-applicable" { |
| 59 | + continue; |
| 60 | + } |
| 61 | + let Some(edit) = &fix.edit else { continue }; |
| 62 | + planned.push(PlannedEdit { |
| 63 | + code: diagnostic.code.clone(), |
| 64 | + title: fix.title.clone(), |
| 65 | + edit: edit.clone(), |
| 66 | + }); |
| 67 | + } |
| 68 | + } |
| 69 | + planned |
| 70 | +} |
| 71 | + |
| 72 | +/// Apply edits to `source`, returning the rewritten text and the edits actually |
| 73 | +/// applied. Edits are applied bottom-to-top (latest position first) so earlier |
| 74 | +/// edits never shift the positions of later ones. Overlapping edits on the same |
| 75 | +/// line are conservatively skipped (the first one in source order wins). |
| 76 | +fn apply_edits(source: &str, edits: &[PlannedEdit]) -> (String, Vec<usize>, Vec<usize>) { |
| 77 | + // Index edits, then order by (line, column) descending. |
| 78 | + let mut order: Vec<usize> = (0..edits.len()).collect(); |
| 79 | + order.sort_by(|&a, &b| { |
| 80 | + let (ea, eb) = (&edits[a].edit.span, &edits[b].edit.span); |
| 81 | + eb.line.cmp(&ea.line).then(eb.column.cmp(&ea.column)) |
| 82 | + }); |
| 83 | + |
| 84 | + let mut lines: Vec<Vec<char>> = source.lines().map(|line| line.chars().collect()).collect(); |
| 85 | + let trailing_newline = source.ends_with('\n'); |
| 86 | + |
| 87 | + // Track applied character ranges per line to reject overlaps. |
| 88 | + let mut claimed: Vec<(usize, usize, usize)> = Vec::new(); // (line, start, end) |
| 89 | + let mut applied = Vec::new(); |
| 90 | + let mut skipped = Vec::new(); |
| 91 | + |
| 92 | + for &i in &order { |
| 93 | + let span = &edits[i].edit.span; |
| 94 | + if span.line == 0 || span.line > lines.len() { |
| 95 | + skipped.push(i); |
| 96 | + continue; |
| 97 | + } |
| 98 | + let line = &mut lines[span.line - 1]; |
| 99 | + let start = span.column.saturating_sub(1); |
| 100 | + if start > line.len() { |
| 101 | + skipped.push(i); |
| 102 | + continue; |
| 103 | + } |
| 104 | + let end = (start + span.length).min(line.len()); |
| 105 | + let overlaps = claimed.iter().any(|&(l, s, e)| { |
| 106 | + if l != span.line { |
| 107 | + return false; |
| 108 | + } |
| 109 | + if start == end { |
| 110 | + // Insertion: only conflicts if strictly inside a replaced range. |
| 111 | + start > s && start < e |
| 112 | + } else { |
| 113 | + // Replacement: conflicts if the character ranges intersect. |
| 114 | + start < e && end > s |
| 115 | + } |
| 116 | + }); |
| 117 | + if overlaps { |
| 118 | + skipped.push(i); |
| 119 | + continue; |
| 120 | + } |
| 121 | + let replacement: Vec<char> = edits[i].edit.replacement.chars().collect(); |
| 122 | + line.splice(start..end, replacement); |
| 123 | + claimed.push((span.line, start, end)); |
| 124 | + applied.push(i); |
| 125 | + } |
| 126 | + |
| 127 | + let mut rebuilt = lines |
| 128 | + .into_iter() |
| 129 | + .map(|line| line.into_iter().collect::<String>()) |
| 130 | + .collect::<Vec<_>>() |
| 131 | + .join("\n"); |
| 132 | + if trailing_newline { |
| 133 | + rebuilt.push('\n'); |
| 134 | + } |
| 135 | + (rebuilt, applied, skipped) |
| 136 | +} |
| 137 | + |
| 138 | +fn analyze(path: &str, source: &str, interfaces: &[(&str, &str)]) -> Vec<Diagnostic> { |
| 139 | + let mut combined = standard_package_interfaces().to_vec(); |
| 140 | + combined.extend(interfaces.iter().copied()); |
| 141 | + analyze_source_with_interfaces(path, source, &combined) |
| 142 | +} |
| 143 | + |
| 144 | +pub(crate) fn run_fix(args: &[String]) -> ExitCode { |
| 145 | + let options = match parse_fix_args(args) { |
| 146 | + Ok(options) => options, |
| 147 | + Err(error) => { |
| 148 | + eprintln!("{error}"); |
| 149 | + return ExitCode::from(2); |
| 150 | + } |
| 151 | + }; |
| 152 | + let Some(path) = options.path else { |
| 153 | + print_usage(); |
| 154 | + return ExitCode::from(2); |
| 155 | + }; |
| 156 | + let source = match fs::read_to_string(path) { |
| 157 | + Ok(source) => source, |
| 158 | + Err(error) => { |
| 159 | + eprintln!("failed to read {path}: {error}"); |
| 160 | + return ExitCode::from(2); |
| 161 | + } |
| 162 | + }; |
| 163 | + let interfaces = match read_interface_sources(&options.interfaces) { |
| 164 | + Ok(interfaces) => interfaces, |
| 165 | + Err(error) => { |
| 166 | + eprintln!("{error}"); |
| 167 | + return ExitCode::from(2); |
| 168 | + } |
| 169 | + }; |
| 170 | + let interface_refs: Vec<(&str, &str)> = interfaces |
| 171 | + .iter() |
| 172 | + .map(|interface| (interface.path.as_str(), interface.contents.as_str())) |
| 173 | + .collect(); |
| 174 | + |
| 175 | + let diagnostics = analyze(path, &source, &interface_refs); |
| 176 | + let planned = plan_edits(&diagnostics); |
| 177 | + let (rewritten, applied, skipped) = apply_edits(&source, &planned); |
| 178 | + |
| 179 | + if options.write |
| 180 | + && !applied.is_empty() |
| 181 | + && let Err(error) = fs::write(path, &rewritten) |
| 182 | + { |
| 183 | + eprintln!("failed to write {path}: {error}"); |
| 184 | + return ExitCode::from(2); |
| 185 | + } |
| 186 | + |
| 187 | + if options.json { |
| 188 | + let fixes: Vec<_> = applied |
| 189 | + .iter() |
| 190 | + .map(|&i| { |
| 191 | + let edit = &planned[i]; |
| 192 | + json!({ |
| 193 | + "code": edit.code, |
| 194 | + "title": edit.title, |
| 195 | + "line": edit.edit.span.line, |
| 196 | + "column": edit.edit.span.column, |
| 197 | + "replacement": edit.edit.replacement, |
| 198 | + }) |
| 199 | + }) |
| 200 | + .collect(); |
| 201 | + println!( |
| 202 | + "{}", |
| 203 | + json!({ |
| 204 | + "ok": true, |
| 205 | + "path": path, |
| 206 | + "written": options.write && !applied.is_empty(), |
| 207 | + "applied": fixes, |
| 208 | + "skipped": skipped.len(), |
| 209 | + "remaining_diagnostics": diagnostics.len().saturating_sub(applied.len()), |
| 210 | + }) |
| 211 | + ); |
| 212 | + return ExitCode::SUCCESS; |
| 213 | + } |
| 214 | + |
| 215 | + if planned.is_empty() { |
| 216 | + println!("{path}: no machine-applicable fixes"); |
| 217 | + return ExitCode::SUCCESS; |
| 218 | + } |
| 219 | + for &i in &applied { |
| 220 | + let edit = &planned[i]; |
| 221 | + println!( |
| 222 | + " {}:{}:{} [{}] {}", |
| 223 | + path, edit.edit.span.line, edit.edit.span.column, edit.code, edit.title |
| 224 | + ); |
| 225 | + } |
| 226 | + if options.write { |
| 227 | + println!("applied {} fix(es) to {path}", applied.len()); |
| 228 | + } else { |
| 229 | + println!( |
| 230 | + "{} fix(es) applicable; re-run with `--write` to apply", |
| 231 | + applied.len() |
| 232 | + ); |
| 233 | + } |
| 234 | + if !skipped.is_empty() { |
| 235 | + println!( |
| 236 | + "{} overlapping fix(es) skipped (re-run after applying)", |
| 237 | + skipped.len() |
| 238 | + ); |
| 239 | + } |
| 240 | + ExitCode::SUCCESS |
| 241 | +} |
0 commit comments