Skip to main content

jsslint_core/
fixer.rs

1//! Auto-fix engine — mirrors `core/fixer.py` in full, including the
2//! file I/O, atomic write, interactive prompting, and
3//! re-validation-by-re-running-the-whole-engine parts of Python's
4//! `apply_fixes` (deferred in the initial port to a pure-logic subset
5//! since a fully assembled rule engine didn't exist yet; Phase 4 now
6//! has one).
7
8use crate::config::ToolConfig;
9use crate::engine::{self, ParsedDocument};
10use crate::report::{ComplianceReport, Fix, FixConfidence, Violation};
11use std::collections::HashSet;
12use std::io::{BufRead, Write};
13use std::path::Path;
14
15/// A violation reduced to what fix-application needs. Mirrors
16/// `fixer.py::_Candidate`.
17#[derive(Debug, Clone)]
18pub struct Candidate {
19    pub file: String,
20    pub fix: Fix,
21    pub rule_id: String,
22    pub line: u32,
23}
24
25/// Every violation carrying a `Fix`, reduced to a `Candidate`. Mirrors
26/// `fixer.py::_candidates`.
27pub fn candidates_from_violations(violations: &[Violation]) -> Vec<Candidate> {
28    violations
29        .iter()
30        .filter_map(|v| {
31            v.fix.as_ref().map(|fix| Candidate {
32                file: v.file.clone(),
33                fix: fix.clone(),
34                rule_id: v.rule_id.clone(),
35                line: v.line,
36            })
37        })
38        .collect()
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum SkipReason {
43    Conflict,
44    RuleNotSelected,
45    UserSkipped,
46}
47
48#[derive(Debug, Clone)]
49pub struct FixSkip {
50    pub file: String,
51    pub fix: Fix,
52    pub rule_id: String,
53    pub reason: SkipReason,
54}
55
56/// Partition fixes for a single file into `(applied, skipped)`: sort
57/// by `Fix.start`, scan and group overlapping intervals into
58/// clusters, pick a deterministic winner per cluster (`safe`
59/// confidence first, then `rule_id`, then `start`, then `end` — the
60/// first cluster member wins any remaining full tie, matching
61/// Python's `min()`). Mirrors `fixer.py::_resolve_conflicts`.
62pub fn resolve_conflicts(candidates: Vec<Candidate>) -> (Vec<Candidate>, Vec<FixSkip>) {
63    if candidates.is_empty() {
64        return (Vec::new(), Vec::new());
65    }
66    let mut ordered = candidates;
67    ordered.sort_by_key(|c| c.fix.start);
68
69    let mut applied = Vec::new();
70    let mut skipped = Vec::new();
71    let mut cluster: Vec<Candidate> = Vec::new();
72    let mut cluster_end: i64 = -1;
73
74    for c in ordered {
75        if c.fix.start as i64 >= cluster_end {
76            flush_cluster(&mut cluster, &mut applied, &mut skipped);
77            cluster_end = c.fix.end as i64;
78            cluster.push(c);
79        } else {
80            cluster_end = cluster_end.max(c.fix.end as i64);
81            cluster.push(c);
82        }
83    }
84    flush_cluster(&mut cluster, &mut applied, &mut skipped);
85    (applied, skipped)
86}
87
88fn flush_cluster(
89    cluster: &mut Vec<Candidate>,
90    applied: &mut Vec<Candidate>,
91    skipped: &mut Vec<FixSkip>,
92) {
93    if cluster.is_empty() {
94        return;
95    }
96    let winner_idx = cluster
97        .iter()
98        .enumerate()
99        .min_by_key(|(_, c)| {
100            (
101                if c.fix.confidence == FixConfidence::Safe {
102                    0u8
103                } else {
104                    1u8
105                },
106                c.rule_id.clone(),
107                c.fix.start,
108                c.fix.end,
109            )
110        })
111        .map(|(i, _)| i)
112        .expect("cluster is non-empty");
113    for (i, c) in cluster.drain(..).enumerate() {
114        if i == winner_idx {
115            applied.push(c);
116        } else {
117            skipped.push(FixSkip {
118                file: c.file.clone(),
119                fix: c.fix.clone(),
120                rule_id: c.rule_id.clone(),
121                reason: SkipReason::Conflict,
122            });
123        }
124    }
125}
126
127/// Apply `fixes` to `source_chars` in reverse-position order so
128/// earlier offsets are not shifted by later edits. Positions are
129/// Unicode codepoint offsets (see `report::Fix`'s doc comment) —
130/// `source_chars` must be the same codepoint-indexed representation
131/// the fixes' offsets were computed against. Mirrors
132/// `fixer.py::_apply_to_text`; that function's own doc comment claims
133/// pylatexenc positions are byte offsets, but Python `str` slicing is
134/// always codepoint-based regardless of what pylatexenc calls it —
135/// already corrected project-wide (see `report::Fix`'s doc comment).
136pub fn apply_to_text(source_chars: &[char], fixes: &[Candidate]) -> String {
137    let mut ordered: Vec<&Candidate> = fixes.iter().collect();
138    ordered.sort_by_key(|c| std::cmp::Reverse(c.fix.start));
139    let mut chars: Vec<char> = source_chars.to_vec();
140    for c in ordered {
141        let mut next = chars[..c.fix.start].to_vec();
142        next.extend(c.fix.replacement.chars());
143        next.extend(&chars[c.fix.end..]);
144        chars = next;
145    }
146    chars.into_iter().collect()
147}
148
149/// Mirrors `fixer.py::ApplyMode`.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum ApplyMode {
152    Write,
153    DryRun,
154    Interactive,
155}
156
157/// Mirrors `fixer.py::FixApplication`.
158#[derive(Debug, Clone)]
159pub struct FixApplication {
160    pub file: String,
161    pub fix: Fix,
162    pub rule_id: String,
163}
164
165/// Mirrors `fixer.py::RejectReason`.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum RejectReason {
168    Regression,
169    PermissionDenied,
170}
171
172/// Mirrors `fixer.py::FixRejection`.
173#[derive(Debug, Clone)]
174pub struct FixRejection {
175    pub file: String,
176    pub fix: Fix,
177    pub rule_id: String,
178    pub reason: RejectReason,
179}
180
181/// Mirrors `fixer.py::FixReport`.
182#[derive(Debug, Clone, Default)]
183pub struct FixReport {
184    pub applied: Vec<FixApplication>,
185    pub skipped: Vec<FixSkip>,
186    pub rejected: Vec<FixRejection>,
187}
188
189/// Splits `text` the way Python's `str.splitlines(keepends=True)` does
190/// for the line endings real manuscripts actually use (`\n`, `\r\n`,
191/// bare `\r`) — deliberately not the full Unicode line-boundary table
192/// (`\v`, `\f`, `\x1c`-`\x1e`, `\x85`, U+2028/U+2029), none of which
193/// appear in the fixture/recall corpus.
194fn splitlines_keepends(text: &str) -> Vec<String> {
195    let mut lines = Vec::new();
196    let mut line_start = 0usize;
197    let mut chars = text.char_indices().peekable();
198    while let Some((i, c)) = chars.next() {
199        if c == '\n' {
200            lines.push(text[line_start..i + 1].to_string());
201            line_start = i + 1;
202        } else if c == '\r' {
203            if let Some(&(j, '\n')) = chars.peek() {
204                chars.next();
205                lines.push(text[line_start..j + 1].to_string());
206                line_start = j + 1;
207            } else {
208                lines.push(text[line_start..i + 1].to_string());
209                line_start = i + 1;
210            }
211        }
212    }
213    if line_start < text.len() {
214        lines.push(text[line_start..].to_string());
215    }
216    lines
217}
218
219/// Mirrors `difflib.py::_format_range_unified` — not exported by the
220/// `difflib` crate (its `utils` module is private), so reimplemented
221/// here; it's five lines.
222fn format_range_unified(start: usize, end: usize) -> String {
223    let mut beginning = start + 1;
224    let length = end - start;
225    if length == 1 {
226        return beginning.to_string();
227    }
228    if length == 0 {
229        beginning -= 1;
230    }
231    format!("{beginning},{length}")
232}
233
234/// Mirrors `fixer.py::_unified_diff` (`difflib.unified_diff`, default
235/// `n=3` context, no `fromfiledate`/`tofiledate`). Built directly on
236/// the `difflib` crate's `SequenceMatcher` (a byte-for-byte port of
237/// Python's, including the `len(b) >= 200` popular-element / autojunk
238/// filtering) rather than the crate's own `unified_diff()` helper,
239/// which unconditionally appends a `\t` header suffix even when
240/// `from_file_date`/`to_file_date` are empty — Python's
241/// `difflib.unified_diff` only adds that `\t` when a date is actually
242/// given (`'\t{}'.format(fromfiledate) if fromfiledate else ''`).
243fn unified_diff_text(file: &str, before: &str, after: &str) -> String {
244    let before_lines = splitlines_keepends(before);
245    let after_lines = splitlines_keepends(after);
246    let mut matcher = difflib::sequencematcher::SequenceMatcher::new(&before_lines, &after_lines);
247    let mut out = String::new();
248    let mut started = false;
249    for group in matcher.get_grouped_opcodes(3) {
250        if !started {
251            started = true;
252            out.push_str(&format!("--- {file}\n"));
253            out.push_str(&format!("+++ {file}\n"));
254        }
255        let first = group.first().expect("grouped opcodes are non-empty");
256        let last = group.last().expect("grouped opcodes are non-empty");
257        let file1_range = format_range_unified(first.first_start, last.first_end);
258        let file2_range = format_range_unified(first.second_start, last.second_end);
259        out.push_str(&format!("@@ -{file1_range} +{file2_range} @@\n"));
260        for code in &group {
261            if code.tag == "equal" {
262                for item in &before_lines[code.first_start..code.first_end] {
263                    out.push(' ');
264                    out.push_str(item);
265                }
266                continue;
267            }
268            if code.tag == "replace" || code.tag == "delete" {
269                for item in &before_lines[code.first_start..code.first_end] {
270                    out.push('-');
271                    out.push_str(item);
272                }
273            }
274            if code.tag == "replace" || code.tag == "insert" {
275                for item in &after_lines[code.second_start..code.second_end] {
276                    out.push('+');
277                    out.push_str(item);
278                }
279            }
280        }
281    }
282    out
283}
284
285/// Tempfile + rename, mirroring `fixer.py::_atomic_write`'s
286/// `tempfile.mkstemp` + `os.replace` (POSIX rename is atomic; exact
287/// temp-file naming isn't part of the observable contract, only that
288/// the target is never left partially written).
289fn atomic_write(target: &Path, contents: &[u8]) -> std::io::Result<()> {
290    let parent = target.parent().unwrap_or_else(|| Path::new("."));
291    let file_name = target
292        .file_name()
293        .map(|n| n.to_string_lossy().into_owned())
294        .unwrap_or_default();
295    let tmp_path = parent.join(format!(
296        ".jss-lint.{}.{}.tmp",
297        std::process::id(),
298        file_name
299    ));
300    {
301        let mut f = std::fs::File::create(&tmp_path)?;
302        f.write_all(contents)?;
303        f.flush()?;
304        let _ = f.sync_all();
305    }
306    std::fs::rename(&tmp_path, target)?;
307    Ok(())
308}
309
310/// Re-parses + re-lints `target` and returns `true` iff none of the
311/// `(rule_id, line)` pairs in `before` re-appear. Mirrors
312/// `fixer.py::_re_validate`, using `ToolConfig::default()` rather than
313/// re-loading a per-directory `.jss-lint.toml` — config-file loading
314/// isn't ported yet (task: "Port .jss-lint.toml config-file loading"),
315/// and `load_config({}, ...)` with no such file present resolves to
316/// the same defaults Python would use today.
317fn re_validate(target: &Path, before: &HashSet<(String, u32)>) -> bool {
318    let Ok(contents) = std::fs::read_to_string(target) else {
319        return true;
320    };
321    let sources = vec![(target.to_string_lossy().into_owned(), contents)];
322    let Ok(document) = ParsedDocument::from_sources(&sources) else {
323        return true;
324    };
325    let cfg = ToolConfig::default();
326    let report = engine::run(&cfg, &document);
327    let after: HashSet<(String, u32)> = report
328        .violations
329        .iter()
330        .map(|v| (v.rule_id.clone(), v.line))
331        .collect();
332    before.is_disjoint(&after)
333}
334
335/// Apply auto-fixes from `report` per spec 008. Mirrors
336/// `fixer.py::apply_fixes`.
337#[allow(clippy::too_many_arguments)]
338pub fn apply_fixes(
339    report: &ComplianceReport,
340    mode: ApplyMode,
341    rules: Option<&HashSet<String>>,
342    stdin: &mut dyn BufRead,
343    stdout: &mut dyn Write,
344    stderr: &mut dyn Write,
345) -> FixReport {
346    let all_candidates = candidates_from_violations(&report.violations);
347    let mut skipped_all: Vec<FixSkip> = Vec::new();
348    let candidates: Vec<Candidate> = if let Some(rules) = rules {
349        let (keep, drop): (Vec<Candidate>, Vec<Candidate>) = all_candidates
350            .into_iter()
351            .partition(|c| rules.contains(&c.rule_id));
352        skipped_all.extend(drop.into_iter().map(|c| FixSkip {
353            file: c.file,
354            fix: c.fix,
355            rule_id: c.rule_id,
356            reason: SkipReason::RuleNotSelected,
357        }));
358        keep
359    } else {
360        all_candidates
361    };
362
363    let mut by_file: std::collections::BTreeMap<String, Vec<Candidate>> =
364        std::collections::BTreeMap::new();
365    for c in candidates {
366        by_file.entry(c.file.clone()).or_default().push(c);
367    }
368
369    let mut applied_all: Vec<FixApplication> = Vec::new();
370    let mut rejected_all: Vec<FixRejection> = Vec::new();
371
372    for (file, fixes) in by_file {
373        let path = Path::new(&file);
374        let (per_file_applied, per_file_skipped) = resolve_conflicts(fixes);
375        skipped_all.extend(per_file_skipped);
376
377        if per_file_applied.is_empty() {
378            continue;
379        }
380
381        let before = match std::fs::read_to_string(path) {
382            Ok(b) => b,
383            Err(exc) => {
384                rejected_all.extend(per_file_applied.iter().map(|c| FixRejection {
385                    file: c.file.clone(),
386                    fix: c.fix.clone(),
387                    rule_id: c.rule_id.clone(),
388                    reason: RejectReason::PermissionDenied,
389                }));
390                let _ = writeln!(stderr, "jss-lint: {file}: {exc}");
391                continue;
392            }
393        };
394
395        let mut accepted: Vec<Candidate> = Vec::new();
396        if mode == ApplyMode::Interactive {
397            let mut idx = 0usize;
398            while idx < per_file_applied.len() {
399                let c = &per_file_applied[idx];
400                let before_chars: Vec<char> = before.chars().collect();
401                let single_after = apply_to_text(&before_chars, std::slice::from_ref(c));
402                let hunk = unified_diff_text(&file, &before, &single_after);
403                let _ = write!(stdout, "{hunk}");
404                let _ = write!(
405                    stdout,
406                    "Apply fix for {} at line {}? [y/n/a/q] ",
407                    c.rule_id, c.line
408                );
409                let _ = stdout.flush();
410                let mut reply = String::new();
411                let read = stdin.read_line(&mut reply);
412                let reply = match read {
413                    Ok(0) | Err(_) => "q".to_string(),
414                    Ok(_) => reply,
415                };
416                match reply.trim().to_lowercase().as_str() {
417                    "y" => {
418                        accepted.push(per_file_applied[idx].clone());
419                        idx += 1;
420                    }
421                    "a" => {
422                        accepted.extend(per_file_applied[idx..].iter().cloned());
423                        break;
424                    }
425                    "q" => {
426                        skipped_all.extend(per_file_applied[idx..].iter().map(|x| FixSkip {
427                            file: x.file.clone(),
428                            fix: x.fix.clone(),
429                            rule_id: x.rule_id.clone(),
430                            reason: SkipReason::UserSkipped,
431                        }));
432                        break;
433                    }
434                    _ => {
435                        skipped_all.push(FixSkip {
436                            file: c.file.clone(),
437                            fix: c.fix.clone(),
438                            rule_id: c.rule_id.clone(),
439                            reason: SkipReason::UserSkipped,
440                        });
441                        idx += 1;
442                    }
443                }
444            }
445        } else {
446            accepted = per_file_applied;
447        }
448
449        if accepted.is_empty() {
450            continue;
451        }
452
453        let before_chars: Vec<char> = before.chars().collect();
454        let after = apply_to_text(&before_chars, &accepted);
455
456        if mode == ApplyMode::DryRun {
457            let _ = write!(stdout, "{}", unified_diff_text(&file, &before, &after));
458            applied_all.extend(accepted.iter().map(|c| FixApplication {
459                file: c.file.clone(),
460                fix: c.fix.clone(),
461                rule_id: c.rule_id.clone(),
462            }));
463            continue;
464        }
465
466        if let Err(exc) = atomic_write(path, after.as_bytes()) {
467            rejected_all.extend(accepted.iter().map(|c| FixRejection {
468                file: c.file.clone(),
469                fix: c.fix.clone(),
470                rule_id: c.rule_id.clone(),
471                reason: RejectReason::PermissionDenied,
472            }));
473            let _ = writeln!(stderr, "jss-lint: {file}: {exc}");
474            continue;
475        }
476
477        let rule_id_lines_before: HashSet<(String, u32)> = accepted
478            .iter()
479            .map(|c| (c.rule_id.clone(), c.line))
480            .collect();
481        if !re_validate(path, &rule_id_lines_before) {
482            let _ = atomic_write(path, before.as_bytes());
483            rejected_all.extend(accepted.iter().map(|c| FixRejection {
484                file: c.file.clone(),
485                fix: c.fix.clone(),
486                rule_id: c.rule_id.clone(),
487                reason: RejectReason::Regression,
488            }));
489            for c in &accepted {
490                let _ = writeln!(
491                    stderr,
492                    "jss-lint: {} fix rejected (regression on rewrite); file rolled back: {}",
493                    c.rule_id, file
494                );
495            }
496            continue;
497        }
498
499        applied_all.extend(accepted.iter().map(|c| FixApplication {
500            file: c.file.clone(),
501            fix: c.fix.clone(),
502            rule_id: c.rule_id.clone(),
503        }));
504    }
505
506    FixReport {
507        applied: applied_all,
508        skipped: skipped_all,
509        rejected: rejected_all,
510    }
511}