1use crate::{Applicability, CheckInfo, Diagnostic, 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)]
25pub struct FixOptions {
26 pub unsafe_fixes: bool,
28 pub promoted: Vec<String>,
30 pub disabled: Vec<String>,
32 pub only: Vec<String>,
34 pub dry_run: bool,
36}
37
38impl FixOptions {
39 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#[derive(Debug, Clone)]
61pub struct Applied {
62 pub file: PathBuf,
64 pub line: usize,
66 pub rule: String,
68 pub applicability: Applicability,
71 pub pass: usize,
74}
75
76#[derive(Debug, Clone)]
78pub struct Skipped {
79 pub file: PathBuf,
81 pub line: usize,
83 pub rule: String,
85 pub reason: String,
89}
90
91#[derive(Debug, Default)]
96pub struct FileOutcome {
97 pub file: PathBuf,
99 pub applied: Vec<Applied>,
101 pub skipped: Vec<Skipped>,
103 pub deferred: usize,
105 pub reverted: Option<String>,
107 pub oscillation: Option<String>,
109 pub contents: Option<String>,
111 pub suggested: Option<String>,
115 pub check: CheckInfo,
117}
118
119pub fn fix_file(h: &Htl, path: &Path, opts: &FixOptions) -> Result<FileOutcome> {
122 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 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 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(¤t, &candidates);
172 if applied_idx.is_empty() {
173 out.deferred = deferred;
174 break;
175 }
176 let target = scratch.as_deref().unwrap_or(path);
178 std::fs::write(target, &next).with_context(|| format!("writing {}", target.display()))?;
179 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 if new_errors > check.errors.len().saturating_sub(fixed_errors) {
192 std::fs::write(target, ¤t)
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 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 if !has_syntax_error(&check) {
230 let sug = suggestions(&check, opts);
231 if !sug.is_empty() {
232 let (text, applied, _) = apply_non_overlapping(¤t, &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 h.check_written(path)?
246 };
247 Ok(out)
248}
249
250fn 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
264fn 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
277fn 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
291fn 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
354fn 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
382fn 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
399fn apply_non_overlapping(src: &str, candidates: &[Candidate]) -> (String, Vec<usize>, usize) {
402 let index = LineIndex::new(src);
403 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 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 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 fn offset(&self, line: usize, col: usize) -> Option<usize> {
474 if line == 0 || col == 0 {
475 return None;
476 }
477 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
504pub 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
559pub 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}