1use 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#[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
25pub 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
56pub 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
127pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum ApplyMode {
152 Write,
153 DryRun,
154 Interactive,
155}
156
157#[derive(Debug, Clone)]
159pub struct FixApplication {
160 pub file: String,
161 pub fix: Fix,
162 pub rule_id: String,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum RejectReason {
168 Regression,
169 PermissionDenied,
170}
171
172#[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#[derive(Debug, Clone, Default)]
183pub struct FixReport {
184 pub applied: Vec<FixApplication>,
185 pub skipped: Vec<FixSkip>,
186 pub rejected: Vec<FixRejection>,
187}
188
189fn 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
219fn 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
234fn 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
285fn 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
310fn 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#[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}