Skip to main content

htl_core/
fix.rs

1//! Applying the fixes that diagnostics carry (`htl fix`).
2//!
3//! The shape follows what cargo fix, ESLint and Ruff settled on: a fix travels with
4//! its diagnostic and has an applicability class; only `safe` applies unless asked;
5//! edits that overlap within one pass are deferred to the next, which re-checks the
6//! file; passes are capped; a fix that leaves the file with an error it did not have
7//! is reverted; everything applied is reported, not only what remains. A file the
8//! parser rejects is never touched; type errors do not block (their positions are
9//! sound, and a fix may be what removes them), the revert is the guard.
10
11use crate::{Applicability, CheckInfo, Edit, Htl};
12use anyhow::{Context, Result, bail};
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15
16/// Passes per file before giving up (cargo fix uses 4).
17pub const MAX_PASSES: usize = 4;
18
19#[derive(Debug, Clone, Default)]
20pub struct FixOptions {
21    /// Apply `unsafe` fixes too.
22    pub unsafe_fixes: bool,
23    /// Rules promoted to safe by `[fix] unsafe` in htl.toml.
24    pub promoted: Vec<String>,
25    /// Rules whose fixes are never applied (`[fix] disable`).
26    pub disabled: Vec<String>,
27    /// Only these rules (`--rule a,b`); empty = all.
28    pub only: Vec<String>,
29    /// Compute everything, write nothing.
30    pub dry_run: bool,
31}
32
33/// One fix that was (or would be) applied.
34#[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/// One fix that was not applied, and why.
44#[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    /// Edits deferred because they overlapped an applied one and the pass cap hit.
58    pub deferred: usize,
59    /// The file was put back as it was because a fix introduced an error.
60    pub reverted: Option<String>,
61    /// Two passes produced the same edit set: the rules named undo each other.
62    pub oscillation: Option<String>,
63    /// The new contents (dry run: what would be written); `None` when unchanged.
64    pub contents: Option<String>,
65    /// Diagnostics after the last pass (what `htl check` would now say).
66    pub check: CheckInfo,
67}
68
69/// Fix one file in place (or in memory with `dry_run`). The checker's search path
70/// must already cover the project.
71pub 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    // A dry run checks from a scratch copy so the tree stays untouched.
82    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        // Type errors elsewhere in the file do not block: positions come from the parse,
100        // which succeeded, and a lint's fix is often what removes the type error (an
101        // `explicit-number` annotation). The re-check below reverts anything that made
102        // the file worse. Only a syntax error (above) blocks.
103        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(&current, &candidates);
119        if applied_idx.is_empty() {
120            out.deferred = deferred;
121            break;
122        }
123        // Write, re-check, keep or revert.
124        let target = scratch.as_deref().unwrap_or(path);
125        std::fs::write(target, &next).with_context(|| format!("writing {}", target.display()))?;
126        // Ask about the file that was just written, not about the one the checker's store
127        // remembers. A dry run got the right answer by accident — it writes to a scratch path
128        // no store entry names — while a real run re-checked the path the store knew and was
129        // handed the result from before the write, then reverted a correct fix for leaving
130        // the error count unchanged.
131        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        // Errors other than the ones just fixed must not have grown.
138        if new_errors > check.errors.len().saturating_sub(fixed_errors) {
139            std::fs::write(target, &current)
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            // Nothing waited on this pass; a further pass would only rediscover new
163            // findings the rewrite created, which the next `htl fix` can take.
164            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        // Same reason as the re-check above: this file may have been written during the loop.
180        h.check_written(path)?
181    };
182    Ok(out)
183}
184
185/// tl's parser errors carry "syntax error" in their text; type errors never do.
186fn 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
199/// Which of the file's fixes may be applied under `opts`; the rest go to `skipped`.
200fn 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
286/// The rule name of a lint line (`... [htl <rule>]`), or a class name for errors.
287fn 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
307/// Apply the candidates whose edits do not overlap an already accepted edit, in
308/// diagnostic order. Returns (new text, applied candidate indexes, deferred count).
309fn 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(); // (start, end, text, candidate)
312    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        // Overlap = a non-empty intersection with an accepted span; two insertions at
331        // one point are fine and keep their order.
332        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    // Apply from the end so earlier offsets stay valid; equal starts keep insertion order.
349    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    /// Byte offset of 1-based (line, col); a col past the line's end clamps to it.
377    fn offset(&self, line: usize, col: usize) -> Option<usize> {
378        if line == 0 || col == 0 {
379            return None;
380        }
381        // One past the last line is allowed for an insertion at the end of the file.
382        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
408/// A unified diff of `before` -> `after` (LCS on lines, 3 lines of context).
409pub 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
463/// Is `path` clean in git? `Ok(None)` when it is not inside a repository.
464pub 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}