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, Diagnostic, 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/// What a run of [`fix_file`] is allowed to do: which fixes count as applicable, which
20/// rules are in scope, and whether anything is written.
21///
22/// The default is the conservative one — safe fixes of every rule, written — so a caller
23/// that sets nothing gets what `htl fix` with no flags does.
24#[derive(Debug, Clone, Default)]
25pub struct FixOptions {
26    /// Apply `unsafe` fixes too.
27    pub unsafe_fixes: bool,
28    /// Rules promoted to safe by `[fix] unsafe` in htl.toml.
29    pub promoted: Vec<String>,
30    /// Rules whose fixes are never applied (`[fix] disable`).
31    pub disabled: Vec<String>,
32    /// Only these rules (`--rule a,b`); empty = all.
33    pub only: Vec<String>,
34    /// Compute everything, write nothing.
35    pub dry_run: bool,
36}
37
38impl FixOptions {
39    /// Refuse a rule name none of these filters could ever match.
40    ///
41    /// The filters are string comparisons against the name a candidate was filed under
42    /// (the rule a diagnostic is filed under), so a name that is not a rule quietly matches nothing: `--rule
43    /// nil-idex` would report a run that fixed nothing, which is what a project with
44    /// nothing to fix also reports. The names come from a person either way — a flag or
45    /// `[fix]` in `htl.toml` — so they are held to the registry the way a lint spec is
46    /// ([`crate::lint::check_fix_rules`]).
47    pub fn validate(&self) -> Result<()> {
48        crate::lint::check_fix_rules(&self.only, "htl fix --rule")?;
49        crate::lint::check_fix_rules(&self.disabled, "[fix] disable")?;
50        crate::lint::check_fix_rules(&self.promoted, "[fix] unsafe")?;
51        Ok(())
52    }
53}
54
55/// One fix that was (or would be) applied.
56///
57/// Reported even on a dry run, and even when a later pass reverted the file: what was
58/// applied is what the run did, and a reader who is told only what remains cannot tell a
59/// quiet run from a busy one that undid itself.
60#[derive(Debug, Clone)]
61pub struct Applied {
62    /// The file as the caller named it — not the scratch path a dry run writes to.
63    pub file: PathBuf,
64    /// The line the diagnostic was on, before this pass's edits moved anything.
65    pub line: usize,
66    /// The rule the fix was filed under, which is what `--rule` and `[fix]` match on.
67    pub rule: String,
68    /// The class it was applied under. `Unsafe` here means the run was asked for it,
69    /// through `--unsafe` or `[fix] unsafe`.
70    pub applicability: Applicability,
71    /// Which pass applied it, counted from 1. More than one means an edit could not land
72    /// until an overlapping one had been applied and the file re-checked.
73    pub pass: usize,
74}
75
76/// One fix that was not applied, and why.
77#[derive(Debug, Clone)]
78pub struct Skipped {
79    /// The file the diagnostic was in.
80    pub file: PathBuf,
81    /// The line it was on.
82    pub line: usize,
83    /// The rule it was filed under.
84    pub rule: String,
85    /// Why it was passed over — a class the run was not asked for, a rule `--rule` or
86    /// `[fix] disable` excluded, a `suggest` that is never applied. A sentence rather
87    /// than a code, because it is printed as one.
88    pub reason: String,
89}
90
91/// Everything one file's run of [`fix_file`] did, and everything it declined to do.
92///
93/// A run that changed nothing still fills this in: the skips are the answer to "why did
94/// `htl fix` do nothing", and without them a filtered run and a clean file look alike.
95#[derive(Debug, Default)]
96pub struct FileOutcome {
97    /// The file this is about.
98    pub file: PathBuf,
99    /// Fixes that landed, in the order the passes applied them.
100    pub applied: Vec<Applied>,
101    /// Fixes that did not, each with its reason.
102    pub skipped: Vec<Skipped>,
103    /// Edits deferred because they overlapped an applied one and the pass cap hit.
104    pub deferred: usize,
105    /// The file was put back as it was because a fix introduced an error.
106    pub reverted: Option<String>,
107    /// Two passes produced the same edit set: the rules named undo each other.
108    pub oscillation: Option<String>,
109    /// The new contents (dry run: what would be written); `None` when unchanged.
110    pub contents: Option<String>,
111    /// The file as the `suggest` fixes would additionally leave it, over whatever was
112    /// applied. Never written: a suggestion is shown and the value in it is the author's
113    /// to choose (`htl fix --diff` is where it is shown). `None` when there are none.
114    pub suggested: Option<String>,
115    /// Diagnostics after the last pass (what `htl check` would now say).
116    pub check: CheckInfo,
117}
118
119/// Fix one file in place (or in memory with `dry_run`). The checker's search path
120/// must already cover the project.
121pub fn fix_file(h: &Htl, path: &Path, opts: &FixOptions) -> Result<FileOutcome> {
122    // Before reading the file: a misspelt rule is a fact about the request, and answering
123    // it after the first file has been rewritten would be the wrong way round.
124    opts.validate()?;
125    let original =
126        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
127    let mut current = original.clone();
128    let mut out = FileOutcome {
129        file: path.to_path_buf(),
130        ..Default::default()
131    };
132    let mut check = h.check(path)?;
133    let mut last_set: Option<BTreeSet<String>> = None;
134    // A dry run checks from a scratch copy so the tree stays untouched.
135    let scratch = if opts.dry_run {
136        Some(scratch_path(path)?)
137    } else {
138        None
139    };
140
141    for pass in 1..=MAX_PASSES {
142        if has_syntax_error(&check) {
143            out.skipped.push(Skipped {
144                file: path.to_path_buf(),
145                line: 0,
146                rule: String::new(),
147                reason: "file has a syntax error; nothing is applied to a tree the parser rejected"
148                    .into(),
149            });
150            break;
151        }
152        // Type errors elsewhere in the file do not block: positions come from the parse,
153        // which succeeded, and a lint's fix is often what removes the type error (an
154        // `explicit-number` annotation). The re-check below reverts anything that made
155        // the file worse. Only a syntax error (above) blocks.
156        let candidates = candidates(&check, opts, &mut out.skipped, path);
157        if candidates.is_empty() {
158            break;
159        }
160        let set: BTreeSet<String> = candidates
161            .iter()
162            .map(|c| format!("{}:{}:{}", c.rule, c.line, c.key))
163            .collect();
164        if last_set.as_ref() == Some(&set) {
165            let rules: BTreeSet<&str> = candidates.iter().map(|c| c.rule.as_str()).collect();
166            out.oscillation = Some(rules.into_iter().collect::<Vec<_>>().join(", "));
167            break;
168        }
169        last_set = Some(set);
170
171        let (next, applied_idx, deferred) = apply_non_overlapping(&current, &candidates);
172        if applied_idx.is_empty() {
173            out.deferred = deferred;
174            break;
175        }
176        // Write, re-check, keep or revert.
177        let target = scratch.as_deref().unwrap_or(path);
178        std::fs::write(target, &next).with_context(|| format!("writing {}", target.display()))?;
179        // Ask about the file that was just written, not about the one the checker's store
180        // remembers. A dry run got the right answer by accident — it writes to a scratch path
181        // no store entry names — while a real run re-checked the path the store knew and was
182        // handed the result from before the write, then reverted a correct fix for leaving
183        // the error count unchanged.
184        let recheck = h.check_written(target)?;
185        let new_errors = recheck.errors.len();
186        let fixed_errors = applied_idx
187            .iter()
188            .filter(|&&i| candidates[i].is_error)
189            .count();
190        // Errors other than the ones just fixed must not have grown.
191        if new_errors > check.errors.len().saturating_sub(fixed_errors) {
192            std::fs::write(target, &current)
193                .with_context(|| format!("restoring {}", target.display()))?;
194            out.reverted = Some(format!(
195                "pass {pass} left {} error(s) where there were {}; the file was put back",
196                new_errors,
197                check.errors.len()
198            ));
199            break;
200        }
201        for &i in &applied_idx {
202            let c = &candidates[i];
203            out.applied.push(Applied {
204                file: path.to_path_buf(),
205                line: c.line,
206                rule: c.rule.clone(),
207                applicability: c.applicability,
208                pass,
209            });
210        }
211        current = next;
212        out.deferred = deferred;
213        check = recheck;
214        if deferred == 0 {
215            // Nothing waited on this pass; a further pass would only rediscover new
216            // findings the rewrite created, which the next `htl fix` can take.
217            break;
218        }
219    }
220    if let Some(s) = &scratch {
221        let _ = std::fs::remove_file(s);
222        if let Some(d) = s.parent() {
223            let _ = std::fs::remove_dir(d);
224        }
225    }
226    // What the suggestions would insert, computed once from the last check and never
227    // written. `candidates` skipped them with a reason; this is the same set from the
228    // other side, so `--diff` can show the edit a person is meant to finish.
229    if !has_syntax_error(&check) {
230        let sug = suggestions(&check, opts);
231        if !sug.is_empty() {
232            let (text, applied, _) = apply_non_overlapping(&current, &sug);
233            if !applied.is_empty() && text != current {
234                out.suggested = Some(text);
235            }
236        }
237    }
238    if current != original {
239        out.contents = Some(current);
240    }
241    out.check = if opts.dry_run && out.contents.is_some() {
242        check
243    } else {
244        // Same reason as the re-check above: this file may have been written during the loop.
245        h.check_written(path)?
246    };
247    Ok(out)
248}
249
250/// tl's parser errors carry "syntax error" in their text; type errors never do.
251fn has_syntax_error(c: &CheckInfo) -> bool {
252    c.errors.iter().any(|e| e.contains("syntax error"))
253}
254
255struct Candidate {
256    rule: String,
257    line: usize,
258    key: String,
259    is_error: bool,
260    applicability: Applicability,
261    edits: Vec<Edit>,
262}
263
264/// Every diagnostic of `check` that carries a fix, errors before lints. Each one arrives
265/// with its position and its rule already read off it (`crate::Diagnostic`), so nothing
266/// here goes back to the printed line to find them.
267fn fixable(check: &CheckInfo) -> Vec<(Diagnostic, crate::Fix, bool)> {
268    check
269        .error_diagnostics()
270        .into_iter()
271        .map(|d| (d, true))
272        .chain(check.lint_diagnostics().into_iter().map(|d| (d, false)))
273        .filter_map(|(mut d, is_error)| d.fix.take().map(|fix| (d, fix, is_error)))
274        .collect()
275}
276
277/// The edits of one fix as a candidate's key: what tells two passes apart.
278fn edit_key(fix: &crate::Fix) -> String {
279    fix.edits
280        .iter()
281        .map(|e| {
282            format!(
283                "{}:{}:{}:{}:{}",
284                e.line, e.col, e.end_line, e.end_col, e.text
285            )
286        })
287        .collect::<Vec<_>>()
288        .join("|")
289}
290
291/// Which of the file's fixes may be applied under `opts`; the rest go to `skipped`.
292fn candidates(
293    check: &CheckInfo,
294    opts: &FixOptions,
295    skipped: &mut Vec<Skipped>,
296    path: &Path,
297) -> Vec<Candidate> {
298    let mut out = Vec::new();
299    for (d, fix, is_error) in fixable(check) {
300        let rule = rule_of(&d, is_error);
301        let line = d.line;
302        if !opts.only.is_empty() && !opts.only.iter().any(|r| r == &rule) {
303            continue;
304        }
305        if opts.disabled.iter().any(|r| r == &rule) {
306            skipped.push(Skipped {
307                file: path.into(),
308                line,
309                rule,
310                reason: "disabled by [fix] disable".into(),
311            });
312            continue;
313        }
314        let promoted = opts.promoted.iter().any(|r| r == &rule);
315        let applicability = if promoted && fix.applicability == Applicability::Unsafe {
316            Applicability::Safe
317        } else {
318            fix.applicability
319        };
320        match applicability {
321            Applicability::Suggest => {
322                skipped.push(Skipped {
323                    file: path.into(),
324                    line,
325                    rule,
326                    reason: "suggestion only; not applied automatically".into(),
327                });
328                continue;
329            }
330            Applicability::Unsafe if !opts.unsafe_fixes => {
331                skipped.push(Skipped {
332                    file: path.into(),
333                    line,
334                    rule,
335                    reason: "unsafe fix; apply with --unsafe or promote it under [fix] unsafe"
336                        .into(),
337                });
338                continue;
339            }
340            _ => {}
341        }
342        out.push(Candidate {
343            rule,
344            line,
345            key: edit_key(&fix),
346            is_error,
347            applicability,
348            edits: fix.edits,
349        });
350    }
351    out
352}
353
354/// The `suggest` fixes of `check`, under the same `--rule` and `[fix] disable` filtering
355/// as the rest. They are never applied to the file; `fix_file` renders them onto a copy
356/// so that what they would insert can be shown.
357fn suggestions(check: &CheckInfo, opts: &FixOptions) -> Vec<Candidate> {
358    let mut out = Vec::new();
359    for (d, fix, is_error) in fixable(check) {
360        if fix.applicability != Applicability::Suggest {
361            continue;
362        }
363        let rule = rule_of(&d, is_error);
364        if !opts.only.is_empty() && !opts.only.iter().any(|r| r == &rule) {
365            continue;
366        }
367        if opts.disabled.iter().any(|r| r == &rule) {
368            continue;
369        }
370        out.push(Candidate {
371            line: d.line,
372            rule,
373            key: edit_key(&fix),
374            is_error,
375            applicability: fix.applicability,
376            edits: fix.edits,
377        });
378    }
379    out
380}
381
382/// What `--rule` and `[fix] disable` name a diagnostic by: a lint's own rule, or a class
383/// name for an error, which has none of its own.
384///
385/// Both classes are registered rules ([`crate::lint::RULES`], `Surfaces::FixOnly`), so a
386/// filter naming one is a filter naming something that exists. `tl:error` is spelt in the
387/// compiler's namespace like its warning kinds, and for the same reason: it is the
388/// compiler speaking, and bare `error` as a rule name would collide with everything.
389fn rule_of(d: &Diagnostic, is_error: bool) -> String {
390    if !is_error && let Some(rule) = &d.rule {
391        return rule.clone();
392    }
393    if d.message.contains("invalid key '") && d.message.contains("is defined at line") {
394        return "forward-ref".into();
395    }
396    "tl:error".into()
397}
398
399/// Apply the candidates whose edits do not overlap an already accepted edit, in
400/// diagnostic order. Returns (new text, applied candidate indexes, deferred count).
401fn apply_non_overlapping(src: &str, candidates: &[Candidate]) -> (String, Vec<usize>, usize) {
402    let index = LineIndex::new(src);
403    // (start, end, text, candidate, edit within it)
404    let mut accepted: Vec<(usize, usize, &str, usize, usize)> = Vec::new();
405    let mut applied = Vec::new();
406    let mut deferred = 0usize;
407    'cand: for (ci, c) in candidates.iter().enumerate() {
408        let mut spans = Vec::new();
409        for e in &c.edits {
410            let (Some(s), Some(t)) = (
411                index.offset(e.line, e.col),
412                index.offset(e.end_line, e.end_col),
413            ) else {
414                deferred += 1;
415                continue 'cand;
416            };
417            if t < s {
418                deferred += 1;
419                continue 'cand;
420            }
421            spans.push((s, t, e.text.as_str()));
422        }
423        // Overlap = a non-empty intersection with an accepted span; two insertions at
424        // one point are fine and keep their order.
425        for (s, t, _) in &spans {
426            for (as_, at, _, _, _) in &accepted {
427                let disjoint = *t <= *as_ || *at <= *s || (*s == *t && *as_ == *at && *s == *as_);
428                let touching_insert = (*s == *t && (*s == *as_ || *s == *at))
429                    || (*as_ == *at && (*as_ == *s || *as_ == *t));
430                if !(disjoint || touching_insert) {
431                    deferred += 1;
432                    continue 'cand;
433                }
434            }
435        }
436        for (ei, (s, t, text)) in spans.into_iter().enumerate() {
437            accepted.push((s, t, text, ci, ei));
438        }
439        applied.push(ci);
440    }
441    // Apply from the end so earlier offsets stay valid. Insertions at one point are
442    // applied back to front — by candidate, then by edit within it — which is what leaves
443    // them in the text in the order they were listed: one fix inserting a field per edit
444    // gets them in the order it named them.
445    accepted.sort_by(|a, b| b.0.cmp(&a.0).then(b.3.cmp(&a.3)).then(b.4.cmp(&a.4)));
446    let mut out = src.to_string();
447    for (s, t, text, _, _) in accepted {
448        out.replace_range(s..t, text);
449    }
450    (out, applied, deferred)
451}
452
453struct LineIndex {
454    starts: Vec<usize>,
455    len: usize,
456}
457
458impl LineIndex {
459    fn new(src: &str) -> Self {
460        let mut starts = vec![0];
461        for (i, b) in src.bytes().enumerate() {
462            if b == b'\n' {
463                starts.push(i + 1);
464            }
465        }
466        Self {
467            starts,
468            len: src.len(),
469        }
470    }
471
472    /// Byte offset of 1-based (line, col); a col past the line's end clamps to it.
473    fn offset(&self, line: usize, col: usize) -> Option<usize> {
474        if line == 0 || col == 0 {
475            return None;
476        }
477        // One past the last line is allowed for an insertion at the end of the file.
478        if line == self.starts.len() + 1 {
479            return Some(self.len);
480        }
481        let start = *self.starts.get(line - 1)?;
482        let end = self.starts.get(line).map(|e| e - 1).unwrap_or(self.len);
483        Some((start + col - 1).min(end.max(start)))
484    }
485}
486
487fn scratch_path(path: &Path) -> Result<PathBuf> {
488    let stem = path
489        .file_name()
490        .and_then(|s| s.to_str())
491        .unwrap_or("file.tl");
492    let dir = std::env::temp_dir().join(format!("htl-fix-{}-{}", std::process::id(), nanos()));
493    std::fs::create_dir_all(&dir)?;
494    Ok(dir.join(stem))
495}
496
497fn nanos() -> u128 {
498    std::time::SystemTime::now()
499        .duration_since(std::time::UNIX_EPOCH)
500        .map(|d| d.as_nanos())
501        .unwrap_or(0)
502}
503
504/// A unified diff of `before` -> `after` (LCS on lines, 3 lines of context).
505pub fn unified_diff(name: &str, before: &str, after: &str) -> String {
506    let a: Vec<&str> = before.lines().collect();
507    let b: Vec<&str> = after.lines().collect();
508    let (n, m) = (a.len(), b.len());
509    let mut l = vec![vec![0usize; m + 1]; n + 1];
510    for i in (0..n).rev() {
511        for j in (0..m).rev() {
512            l[i][j] = if a[i] == b[j] {
513                l[i + 1][j + 1] + 1
514            } else {
515                l[i + 1][j].max(l[i][j + 1])
516            };
517        }
518    }
519    let (mut i, mut j) = (0, 0);
520    let mut ops: Vec<(char, &str)> = Vec::new();
521    while i < n || j < m {
522        if i < n && j < m && a[i] == b[j] {
523            ops.push((' ', a[i]));
524            i += 1;
525            j += 1;
526        } else if i < n && (j >= m || l[i + 1][j] >= l[i][j + 1]) {
527            ops.push(('-', a[i]));
528            i += 1;
529        } else {
530            ops.push(('+', b[j]));
531            j += 1;
532        }
533    }
534    let mut keep = vec![false; ops.len()];
535    for (k, op) in ops.iter().enumerate() {
536        if op.0 != ' ' {
537            let hi = (k + 4).min(ops.len());
538            for slot in &mut keep[k.saturating_sub(3)..hi] {
539                *slot = true;
540            }
541        }
542    }
543    let mut out = format!("--- {name}\n+++ {name}\n");
544    let mut last = usize::MAX;
545    for (k, op) in ops.iter().enumerate() {
546        if keep[k] {
547            if last != usize::MAX && k > last + 1 {
548                out.push_str("@@\n");
549            }
550            out.push(op.0);
551            out.push_str(op.1);
552            out.push('\n');
553            last = k;
554        }
555    }
556    out
557}
558
559/// Is `path` clean in git? `Ok(None)` when it is not inside a repository.
560pub fn git_dirty(path: &Path) -> Result<Option<bool>> {
561    let dir = path.parent().unwrap_or(Path::new("."));
562    let out = std::process::Command::new("git")
563        .args(["status", "--porcelain", "--"])
564        .arg(path.file_name().unwrap_or_default())
565        .current_dir(dir)
566        .output();
567    match out {
568        Ok(o) if o.status.success() => Ok(Some(!o.stdout.is_empty())),
569        Ok(_) => Ok(None),
570        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
571        Err(e) => bail!("running git status: {e}"),
572    }
573}