1use crate::{Applicability, CheckInfo, Edit, Htl};
12use anyhow::{Context, Result, bail};
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16pub const MAX_PASSES: usize = 4;
18
19#[derive(Debug, Clone, Default)]
20pub struct FixOptions {
21 pub unsafe_fixes: bool,
23 pub promoted: Vec<String>,
25 pub disabled: Vec<String>,
27 pub only: Vec<String>,
29 pub dry_run: bool,
31}
32
33#[derive(Debug, Clone)]
35pub struct Applied {
36 pub file: PathBuf,
37 pub line: usize,
38 pub rule: String,
39 pub applicability: Applicability,
40 pub pass: usize,
41}
42
43#[derive(Debug, Clone)]
45pub struct Skipped {
46 pub file: PathBuf,
47 pub line: usize,
48 pub rule: String,
49 pub reason: String,
50}
51
52#[derive(Debug, Default)]
53pub struct FileOutcome {
54 pub file: PathBuf,
55 pub applied: Vec<Applied>,
56 pub skipped: Vec<Skipped>,
57 pub deferred: usize,
59 pub reverted: Option<String>,
61 pub oscillation: Option<String>,
63 pub contents: Option<String>,
65 pub check: CheckInfo,
67}
68
69pub fn fix_file(h: &Htl, path: &Path, opts: &FixOptions) -> Result<FileOutcome> {
72 let original =
73 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
74 let mut current = original.clone();
75 let mut out = FileOutcome {
76 file: path.to_path_buf(),
77 ..Default::default()
78 };
79 let mut check = h.check(path)?;
80 let mut last_set: Option<BTreeSet<String>> = None;
81 let scratch = if opts.dry_run {
83 Some(scratch_path(path)?)
84 } else {
85 None
86 };
87
88 for pass in 1..=MAX_PASSES {
89 if has_syntax_error(&check) {
90 out.skipped.push(Skipped {
91 file: path.to_path_buf(),
92 line: 0,
93 rule: String::new(),
94 reason: "file has a syntax error; nothing is applied to a tree the parser rejected"
95 .into(),
96 });
97 break;
98 }
99 let candidates = candidates(&check, opts, &mut out.skipped, path);
104 if candidates.is_empty() {
105 break;
106 }
107 let set: BTreeSet<String> = candidates
108 .iter()
109 .map(|c| format!("{}:{}:{}", c.rule, c.line, c.key))
110 .collect();
111 if last_set.as_ref() == Some(&set) {
112 let rules: BTreeSet<&str> = candidates.iter().map(|c| c.rule.as_str()).collect();
113 out.oscillation = Some(rules.into_iter().collect::<Vec<_>>().join(", "));
114 break;
115 }
116 last_set = Some(set);
117
118 let (next, applied_idx, deferred) = apply_non_overlapping(¤t, &candidates);
119 if applied_idx.is_empty() {
120 out.deferred = deferred;
121 break;
122 }
123 let target = scratch.as_deref().unwrap_or(path);
125 std::fs::write(target, &next).with_context(|| format!("writing {}", target.display()))?;
126 let recheck = h.check_written(target)?;
132 let new_errors = recheck.errors.len();
133 let fixed_errors = applied_idx
134 .iter()
135 .filter(|&&i| candidates[i].is_error)
136 .count();
137 if new_errors > check.errors.len().saturating_sub(fixed_errors) {
139 std::fs::write(target, ¤t)
140 .with_context(|| format!("restoring {}", target.display()))?;
141 out.reverted = Some(format!(
142 "pass {pass} left {} error(s) where there were {}; the file was put back",
143 new_errors,
144 check.errors.len()
145 ));
146 break;
147 }
148 for &i in &applied_idx {
149 let c = &candidates[i];
150 out.applied.push(Applied {
151 file: path.to_path_buf(),
152 line: c.line,
153 rule: c.rule.clone(),
154 applicability: c.applicability,
155 pass,
156 });
157 }
158 current = next;
159 out.deferred = deferred;
160 check = recheck;
161 if deferred == 0 {
162 break;
165 }
166 }
167 if let Some(s) = &scratch {
168 let _ = std::fs::remove_file(s);
169 if let Some(d) = s.parent() {
170 let _ = std::fs::remove_dir(d);
171 }
172 }
173 if current != original {
174 out.contents = Some(current);
175 }
176 out.check = if opts.dry_run && out.contents.is_some() {
177 check
178 } else {
179 h.check_written(path)?
181 };
182 Ok(out)
183}
184
185fn has_syntax_error(c: &CheckInfo) -> bool {
187 c.errors.iter().any(|e| e.contains("syntax error"))
188}
189
190struct Candidate {
191 rule: String,
192 line: usize,
193 key: String,
194 is_error: bool,
195 applicability: Applicability,
196 edits: Vec<Edit>,
197}
198
199fn candidates(
201 check: &CheckInfo,
202 opts: &FixOptions,
203 skipped: &mut Vec<Skipped>,
204 path: &Path,
205) -> Vec<Candidate> {
206 let mut out = Vec::new();
207 let items = check
208 .errors
209 .iter()
210 .zip(check.error_fixes.iter())
211 .map(|(m, f)| (m, f, true))
212 .chain(
213 check
214 .lints
215 .iter()
216 .zip(check.lint_fixes.iter())
217 .map(|(m, f)| (m, f, false)),
218 );
219 for (msg, fix, is_error) in items {
220 let Some(fix) = fix else { continue };
221 let rule = rule_of(msg, is_error);
222 let line = line_of(msg);
223 if !opts.only.is_empty() && !opts.only.iter().any(|r| r == &rule) {
224 continue;
225 }
226 if opts.disabled.iter().any(|r| r == &rule) {
227 skipped.push(Skipped {
228 file: path.into(),
229 line,
230 rule,
231 reason: "disabled by [fix] disable".into(),
232 });
233 continue;
234 }
235 let promoted = opts.promoted.iter().any(|r| r == &rule);
236 let applicability = if promoted && fix.applicability == Applicability::Unsafe {
237 Applicability::Safe
238 } else {
239 fix.applicability
240 };
241 match applicability {
242 Applicability::Suggest => {
243 skipped.push(Skipped {
244 file: path.into(),
245 line,
246 rule,
247 reason: "suggestion only; not applied automatically".into(),
248 });
249 continue;
250 }
251 Applicability::Unsafe if !opts.unsafe_fixes => {
252 skipped.push(Skipped {
253 file: path.into(),
254 line,
255 rule,
256 reason: "unsafe fix; apply with --unsafe or promote it under [fix] unsafe"
257 .into(),
258 });
259 continue;
260 }
261 _ => {}
262 }
263 let key = fix
264 .edits
265 .iter()
266 .map(|e| {
267 format!(
268 "{}:{}:{}:{}:{}",
269 e.line, e.col, e.end_line, e.end_col, e.text
270 )
271 })
272 .collect::<Vec<_>>()
273 .join("|");
274 out.push(Candidate {
275 rule,
276 line,
277 key,
278 is_error,
279 applicability,
280 edits: fix.edits.clone(),
281 });
282 }
283 out
284}
285
286fn rule_of(msg: &str, is_error: bool) -> String {
288 if !is_error
289 && msg.ends_with(']')
290 && let Some(start) = msg.rfind(" [htl ")
291 {
292 return msg[start + 6..msg.len() - 1].to_string();
293 }
294 if msg.contains("invalid key '") && msg.contains("is defined at line") {
295 return "forward-ref".into();
296 }
297 "error".into()
298}
299
300fn line_of(msg: &str) -> usize {
301 msg.split(':')
302 .nth(1)
303 .and_then(|s| s.trim().parse().ok())
304 .unwrap_or(0)
305}
306
307fn apply_non_overlapping(src: &str, candidates: &[Candidate]) -> (String, Vec<usize>, usize) {
310 let index = LineIndex::new(src);
311 let mut accepted: Vec<(usize, usize, &str, usize)> = Vec::new(); let mut applied = Vec::new();
313 let mut deferred = 0usize;
314 'cand: for (ci, c) in candidates.iter().enumerate() {
315 let mut spans = Vec::new();
316 for e in &c.edits {
317 let (Some(s), Some(t)) = (
318 index.offset(e.line, e.col),
319 index.offset(e.end_line, e.end_col),
320 ) else {
321 deferred += 1;
322 continue 'cand;
323 };
324 if t < s {
325 deferred += 1;
326 continue 'cand;
327 }
328 spans.push((s, t, e.text.as_str()));
329 }
330 for (s, t, _) in &spans {
333 for (as_, at, _, _) in &accepted {
334 let disjoint = *t <= *as_ || *at <= *s || (*s == *t && *as_ == *at && *s == *as_);
335 let touching_insert = (*s == *t && (*s == *as_ || *s == *at))
336 || (*as_ == *at && (*as_ == *s || *as_ == *t));
337 if !(disjoint || touching_insert) {
338 deferred += 1;
339 continue 'cand;
340 }
341 }
342 }
343 for (s, t, text) in spans {
344 accepted.push((s, t, text, ci));
345 }
346 applied.push(ci);
347 }
348 accepted.sort_by(|a, b| b.0.cmp(&a.0).then(b.3.cmp(&a.3)));
350 let mut out = src.to_string();
351 for (s, t, text, _) in accepted {
352 out.replace_range(s..t, text);
353 }
354 (out, applied, deferred)
355}
356
357struct LineIndex {
358 starts: Vec<usize>,
359 len: usize,
360}
361
362impl LineIndex {
363 fn new(src: &str) -> Self {
364 let mut starts = vec![0];
365 for (i, b) in src.bytes().enumerate() {
366 if b == b'\n' {
367 starts.push(i + 1);
368 }
369 }
370 Self {
371 starts,
372 len: src.len(),
373 }
374 }
375
376 fn offset(&self, line: usize, col: usize) -> Option<usize> {
378 if line == 0 || col == 0 {
379 return None;
380 }
381 if line == self.starts.len() + 1 {
383 return Some(self.len);
384 }
385 let start = *self.starts.get(line - 1)?;
386 let end = self.starts.get(line).map(|e| e - 1).unwrap_or(self.len);
387 Some((start + col - 1).min(end.max(start)))
388 }
389}
390
391fn scratch_path(path: &Path) -> Result<PathBuf> {
392 let stem = path
393 .file_name()
394 .and_then(|s| s.to_str())
395 .unwrap_or("file.tl");
396 let dir = std::env::temp_dir().join(format!("htl-fix-{}-{}", std::process::id(), nanos()));
397 std::fs::create_dir_all(&dir)?;
398 Ok(dir.join(stem))
399}
400
401fn nanos() -> u128 {
402 std::time::SystemTime::now()
403 .duration_since(std::time::UNIX_EPOCH)
404 .map(|d| d.as_nanos())
405 .unwrap_or(0)
406}
407
408pub fn unified_diff(name: &str, before: &str, after: &str) -> String {
410 let a: Vec<&str> = before.lines().collect();
411 let b: Vec<&str> = after.lines().collect();
412 let (n, m) = (a.len(), b.len());
413 let mut l = vec![vec![0usize; m + 1]; n + 1];
414 for i in (0..n).rev() {
415 for j in (0..m).rev() {
416 l[i][j] = if a[i] == b[j] {
417 l[i + 1][j + 1] + 1
418 } else {
419 l[i + 1][j].max(l[i][j + 1])
420 };
421 }
422 }
423 let (mut i, mut j) = (0, 0);
424 let mut ops: Vec<(char, &str)> = Vec::new();
425 while i < n || j < m {
426 if i < n && j < m && a[i] == b[j] {
427 ops.push((' ', a[i]));
428 i += 1;
429 j += 1;
430 } else if i < n && (j >= m || l[i + 1][j] >= l[i][j + 1]) {
431 ops.push(('-', a[i]));
432 i += 1;
433 } else {
434 ops.push(('+', b[j]));
435 j += 1;
436 }
437 }
438 let mut keep = vec![false; ops.len()];
439 for (k, op) in ops.iter().enumerate() {
440 if op.0 != ' ' {
441 let hi = (k + 4).min(ops.len());
442 for slot in &mut keep[k.saturating_sub(3)..hi] {
443 *slot = true;
444 }
445 }
446 }
447 let mut out = format!("--- {name}\n+++ {name}\n");
448 let mut last = usize::MAX;
449 for (k, op) in ops.iter().enumerate() {
450 if keep[k] {
451 if last != usize::MAX && k > last + 1 {
452 out.push_str("@@\n");
453 }
454 out.push(op.0);
455 out.push_str(op.1);
456 out.push('\n');
457 last = k;
458 }
459 }
460 out
461}
462
463pub fn git_dirty(path: &Path) -> Result<Option<bool>> {
465 let dir = path.parent().unwrap_or(Path::new("."));
466 let out = std::process::Command::new("git")
467 .args(["status", "--porcelain", "--"])
468 .arg(path.file_name().unwrap_or_default())
469 .current_dir(dir)
470 .output();
471 match out {
472 Ok(o) if o.status.success() => Ok(Some(!o.stdout.is_empty())),
473 Ok(_) => Ok(None),
474 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
475 Err(e) => bail!("running git status: {e}"),
476 }
477}