use crate::{Applicability, CheckInfo, Diagnostic, Edit, Htl};
use anyhow::{Context, Result, bail};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub const MAX_PASSES: usize = 4;
#[derive(Debug, Clone, Default)]
pub struct FixOptions {
pub unsafe_fixes: bool,
pub promoted: Vec<String>,
pub disabled: Vec<String>,
pub only: Vec<String>,
pub dry_run: bool,
}
impl FixOptions {
pub fn validate(&self) -> Result<()> {
crate::lint::check_fix_rules(&self.only, "htl fix --rule")?;
crate::lint::check_fix_rules(&self.disabled, "[fix] disable")?;
crate::lint::check_fix_rules(&self.promoted, "[fix] unsafe")?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Applied {
pub file: PathBuf,
pub line: usize,
pub rule: String,
pub applicability: Applicability,
pub pass: usize,
}
#[derive(Debug, Clone)]
pub struct Skipped {
pub file: PathBuf,
pub line: usize,
pub rule: String,
pub reason: String,
}
#[derive(Debug, Default)]
pub struct FileOutcome {
pub file: PathBuf,
pub applied: Vec<Applied>,
pub skipped: Vec<Skipped>,
pub deferred: usize,
pub reverted: Option<String>,
pub oscillation: Option<String>,
pub contents: Option<String>,
pub suggested: Option<String>,
pub check: CheckInfo,
}
pub fn fix_file(h: &Htl, path: &Path, opts: &FixOptions) -> Result<FileOutcome> {
opts.validate()?;
let original =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let mut current = original.clone();
let mut out = FileOutcome {
file: path.to_path_buf(),
..Default::default()
};
let mut check = h.check(path)?;
let mut last_set: Option<BTreeSet<String>> = None;
let scratch = if opts.dry_run {
Some(scratch_path(path)?)
} else {
None
};
for pass in 1..=MAX_PASSES {
if has_syntax_error(&check) {
out.skipped.push(Skipped {
file: path.to_path_buf(),
line: 0,
rule: String::new(),
reason: "file has a syntax error; nothing is applied to a tree the parser rejected"
.into(),
});
break;
}
let candidates = candidates(&check, opts, &mut out.skipped, path);
if candidates.is_empty() {
break;
}
let set: BTreeSet<String> = candidates
.iter()
.map(|c| format!("{}:{}:{}", c.rule, c.line, c.key))
.collect();
if last_set.as_ref() == Some(&set) {
let rules: BTreeSet<&str> = candidates.iter().map(|c| c.rule.as_str()).collect();
out.oscillation = Some(rules.into_iter().collect::<Vec<_>>().join(", "));
break;
}
last_set = Some(set);
let (next, applied_idx, deferred) = apply_non_overlapping(¤t, &candidates);
if applied_idx.is_empty() {
out.deferred = deferred;
break;
}
let target = scratch.as_deref().unwrap_or(path);
std::fs::write(target, &next).with_context(|| format!("writing {}", target.display()))?;
let recheck = h.check_written(target)?;
let new_errors = recheck.errors.len();
let fixed_errors = applied_idx
.iter()
.filter(|&&i| candidates[i].is_error)
.count();
if new_errors > check.errors.len().saturating_sub(fixed_errors) {
std::fs::write(target, ¤t)
.with_context(|| format!("restoring {}", target.display()))?;
out.reverted = Some(format!(
"pass {pass} left {} error(s) where there were {}; the file was put back",
new_errors,
check.errors.len()
));
break;
}
for &i in &applied_idx {
let c = &candidates[i];
out.applied.push(Applied {
file: path.to_path_buf(),
line: c.line,
rule: c.rule.clone(),
applicability: c.applicability,
pass,
});
}
current = next;
out.deferred = deferred;
check = recheck;
if deferred == 0 {
break;
}
}
if let Some(s) = &scratch {
let _ = std::fs::remove_file(s);
if let Some(d) = s.parent() {
let _ = std::fs::remove_dir(d);
}
}
if !has_syntax_error(&check) {
let sug = suggestions(&check, opts);
if !sug.is_empty() {
let (text, applied, _) = apply_non_overlapping(¤t, &sug);
if !applied.is_empty() && text != current {
out.suggested = Some(text);
}
}
}
if current != original {
out.contents = Some(current);
}
out.check = if opts.dry_run && out.contents.is_some() {
check
} else {
h.check_written(path)?
};
Ok(out)
}
fn has_syntax_error(c: &CheckInfo) -> bool {
c.errors.iter().any(|e| e.contains("syntax error"))
}
struct Candidate {
rule: String,
line: usize,
key: String,
is_error: bool,
applicability: Applicability,
edits: Vec<Edit>,
}
fn fixable(check: &CheckInfo) -> Vec<(Diagnostic, crate::Fix, bool)> {
check
.error_diagnostics()
.into_iter()
.map(|d| (d, true))
.chain(check.lint_diagnostics().into_iter().map(|d| (d, false)))
.filter_map(|(mut d, is_error)| d.fix.take().map(|fix| (d, fix, is_error)))
.collect()
}
fn edit_key(fix: &crate::Fix) -> String {
fix.edits
.iter()
.map(|e| {
format!(
"{}:{}:{}:{}:{}",
e.line, e.col, e.end_line, e.end_col, e.text
)
})
.collect::<Vec<_>>()
.join("|")
}
fn candidates(
check: &CheckInfo,
opts: &FixOptions,
skipped: &mut Vec<Skipped>,
path: &Path,
) -> Vec<Candidate> {
let mut out = Vec::new();
for (d, fix, is_error) in fixable(check) {
let rule = rule_of(&d, is_error);
let line = d.line;
if !opts.only.is_empty() && !opts.only.iter().any(|r| r == &rule) {
continue;
}
if opts.disabled.iter().any(|r| r == &rule) {
skipped.push(Skipped {
file: path.into(),
line,
rule,
reason: "disabled by [fix] disable".into(),
});
continue;
}
let promoted = opts.promoted.iter().any(|r| r == &rule);
let applicability = if promoted && fix.applicability == Applicability::Unsafe {
Applicability::Safe
} else {
fix.applicability
};
match applicability {
Applicability::Suggest => {
skipped.push(Skipped {
file: path.into(),
line,
rule,
reason: "suggestion only; not applied automatically".into(),
});
continue;
}
Applicability::Unsafe if !opts.unsafe_fixes => {
skipped.push(Skipped {
file: path.into(),
line,
rule,
reason: "unsafe fix; apply with --unsafe or promote it under [fix] unsafe"
.into(),
});
continue;
}
_ => {}
}
out.push(Candidate {
rule,
line,
key: edit_key(&fix),
is_error,
applicability,
edits: fix.edits,
});
}
out
}
fn suggestions(check: &CheckInfo, opts: &FixOptions) -> Vec<Candidate> {
let mut out = Vec::new();
for (d, fix, is_error) in fixable(check) {
if fix.applicability != Applicability::Suggest {
continue;
}
let rule = rule_of(&d, is_error);
if !opts.only.is_empty() && !opts.only.iter().any(|r| r == &rule) {
continue;
}
if opts.disabled.iter().any(|r| r == &rule) {
continue;
}
out.push(Candidate {
line: d.line,
rule,
key: edit_key(&fix),
is_error,
applicability: fix.applicability,
edits: fix.edits,
});
}
out
}
fn rule_of(d: &Diagnostic, is_error: bool) -> String {
if !is_error && let Some(rule) = &d.rule {
return rule.clone();
}
if d.message.contains("invalid key '") && d.message.contains("is defined at line") {
return "forward-ref".into();
}
"tl:error".into()
}
fn apply_non_overlapping(src: &str, candidates: &[Candidate]) -> (String, Vec<usize>, usize) {
let index = LineIndex::new(src);
let mut accepted: Vec<(usize, usize, &str, usize, usize)> = Vec::new();
let mut applied = Vec::new();
let mut deferred = 0usize;
'cand: for (ci, c) in candidates.iter().enumerate() {
let mut spans = Vec::new();
for e in &c.edits {
let (Some(s), Some(t)) = (
index.offset(e.line, e.col),
index.offset(e.end_line, e.end_col),
) else {
deferred += 1;
continue 'cand;
};
if t < s {
deferred += 1;
continue 'cand;
}
spans.push((s, t, e.text.as_str()));
}
for (s, t, _) in &spans {
for (as_, at, _, _, _) in &accepted {
let disjoint = *t <= *as_ || *at <= *s || (*s == *t && *as_ == *at && *s == *as_);
let touching_insert = (*s == *t && (*s == *as_ || *s == *at))
|| (*as_ == *at && (*as_ == *s || *as_ == *t));
if !(disjoint || touching_insert) {
deferred += 1;
continue 'cand;
}
}
}
for (ei, (s, t, text)) in spans.into_iter().enumerate() {
accepted.push((s, t, text, ci, ei));
}
applied.push(ci);
}
accepted.sort_by(|a, b| b.0.cmp(&a.0).then(b.3.cmp(&a.3)).then(b.4.cmp(&a.4)));
let mut out = src.to_string();
for (s, t, text, _, _) in accepted {
out.replace_range(s..t, text);
}
(out, applied, deferred)
}
struct LineIndex {
starts: Vec<usize>,
len: usize,
}
impl LineIndex {
fn new(src: &str) -> Self {
let mut starts = vec![0];
for (i, b) in src.bytes().enumerate() {
if b == b'\n' {
starts.push(i + 1);
}
}
Self {
starts,
len: src.len(),
}
}
fn offset(&self, line: usize, col: usize) -> Option<usize> {
if line == 0 || col == 0 {
return None;
}
if line == self.starts.len() + 1 {
return Some(self.len);
}
let start = *self.starts.get(line - 1)?;
let end = self.starts.get(line).map(|e| e - 1).unwrap_or(self.len);
Some((start + col - 1).min(end.max(start)))
}
}
fn scratch_path(path: &Path) -> Result<PathBuf> {
let stem = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file.tl");
let dir = std::env::temp_dir().join(format!("htl-fix-{}-{}", std::process::id(), nanos()));
std::fs::create_dir_all(&dir)?;
Ok(dir.join(stem))
}
fn nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
pub fn unified_diff(name: &str, before: &str, after: &str) -> String {
let a: Vec<&str> = before.lines().collect();
let b: Vec<&str> = after.lines().collect();
let (n, m) = (a.len(), b.len());
let mut l = vec![vec![0usize; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
l[i][j] = if a[i] == b[j] {
l[i + 1][j + 1] + 1
} else {
l[i + 1][j].max(l[i][j + 1])
};
}
}
let (mut i, mut j) = (0, 0);
let mut ops: Vec<(char, &str)> = Vec::new();
while i < n || j < m {
if i < n && j < m && a[i] == b[j] {
ops.push((' ', a[i]));
i += 1;
j += 1;
} else if i < n && (j >= m || l[i + 1][j] >= l[i][j + 1]) {
ops.push(('-', a[i]));
i += 1;
} else {
ops.push(('+', b[j]));
j += 1;
}
}
let mut keep = vec![false; ops.len()];
for (k, op) in ops.iter().enumerate() {
if op.0 != ' ' {
let hi = (k + 4).min(ops.len());
for slot in &mut keep[k.saturating_sub(3)..hi] {
*slot = true;
}
}
}
let mut out = format!("--- {name}\n+++ {name}\n");
let mut last = usize::MAX;
for (k, op) in ops.iter().enumerate() {
if keep[k] {
if last != usize::MAX && k > last + 1 {
out.push_str("@@\n");
}
out.push(op.0);
out.push_str(op.1);
out.push('\n');
last = k;
}
}
out
}
pub fn git_dirty(path: &Path) -> Result<Option<bool>> {
let dir = path.parent().unwrap_or(Path::new("."));
let out = std::process::Command::new("git")
.args(["status", "--porcelain", "--"])
.arg(path.file_name().unwrap_or_default())
.current_dir(dir)
.output();
match out {
Ok(o) if o.status.success() => Ok(Some(!o.stdout.is_empty())),
Ok(_) => Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => bail!("running git status: {e}"),
}
}