use std::collections::HashMap;
use std::process::ExitCode;
use super::ResolvedRun;
use crate::{gate, lint_input, FileReport, Finding, Fix, FixEdit, Severity};
const STDIN_NAME: &str = "<stdin>";
pub(super) enum Mode {
Apply,
Diff,
}
fn error_counts(findings: &[Finding]) -> HashMap<&str, usize> {
let mut counts = HashMap::new();
for f in findings {
if !f.is_suppressed() && f.severity >= Severity::Error {
*counts.entry(f.rule_id.as_str()).or_insert(0) += 1;
}
}
counts
}
fn introduces_new_error(before: &[Finding], after: &[Finding]) -> bool {
let baseline = error_counts(before);
error_counts(after)
.into_iter()
.any(|(rule, n)| n > baseline.get(rule).copied().unwrap_or(0))
}
pub(super) fn run(r: &ResolvedRun, mode: Mode) -> ExitCode {
let mut had_error = false;
let mut gated = false;
for (name, sql) in &r.inputs {
let lint = |s: &str| lint_input(name.clone(), s, &r.options_for(name));
let fp = fix_to_fixpoint(sql, lint, MAX_FIX_ITERATIONS);
if let Some(err) = &fp.final_report.error {
eprintln!("error: {name}: {err}");
had_error = true;
continue;
}
if let Termination::Withheld(Withheld::ParseBroke(err)) = &fp.termination {
let tail = match mode {
Mode::Apply => "file left unchanged",
Mode::Diff => "no diff shown",
};
eprintln!(
"error: {name}: applying fixes produced SQL that no longer parses ({err}); {tail}"
);
had_error = true;
continue;
}
let changed = fp.sql != *sql;
let withheld_note = |verb: &str| -> Option<String> {
matches!(fp.termination, Termination::Withheld(Withheld::NewError)).then(|| {
if changed {
format!(
"{name}: some further fixes withheld — applying them would introduce a new issue; {verb} the safe ones"
)
} else {
format!(
"{name}: fixes withheld — applying them would introduce a new issue; lint `{name}` to see the findings"
)
}
})
};
match mode {
Mode::Diff => {
if let Some(note) = withheld_note("previewing") {
eprintln!("{note}");
if !changed {
if gate(&fp.original_findings, r.fail_on) {
gated = true;
}
continue;
}
}
if matches!(fp.termination, Termination::CapHit) {
eprintln!(
"{name}: fixes did not converge after {} iterations; showing the last stable result",
fp.iterations
);
}
let diff = match &fp.edits {
Some(edits) => render_diff(name, sql, edits),
None => render_diff_strings(name, sql, &fp.sql),
};
print!("{diff}");
if gate(&fp.original_findings, r.fail_on) {
gated = true;
let residual = fp.unfixable + fp.skipped_overlapping;
if residual > 0 {
eprintln!(
"{name}: {residual} finding(s) have no automatic fix or were skipped (overlapping another fix); lint `{name}` to see them"
);
}
}
}
Mode::Apply => {
if name == STDIN_NAME {
print!("{}", fp.sql);
} else if changed {
if let Err(e) = write_atomic(name, &fp.sql) {
eprintln!("error: {name}: {e}");
had_error = true;
continue;
}
}
if let Some(note) = withheld_note("wrote") {
eprintln!("{note}");
}
if matches!(fp.termination, Termination::CapHit) {
let verb = if changed { "wrote" } else { "kept" };
eprintln!(
"{name}: fixes did not converge after {} iterations; {verb} the last stable result",
fp.iterations
);
}
if changed {
let mut note = String::new();
if fp.unfixable > 0 {
note.push_str(&format!("{} unfixable", fp.unfixable));
}
if fp.skipped_overlapping > 0 {
if !note.is_empty() {
note.push_str(", ");
}
note.push_str(&format!("{} skipped-overlapping", fp.skipped_overlapping));
}
let suffix = if note.is_empty() {
String::new()
} else {
format!(" ({note})")
};
eprintln!("fixed {} findings in {name}{suffix}", fp.applied);
}
if gate(&fp.final_report.findings, r.fail_on) {
gated = true;
if !changed {
let remaining = fp
.final_report
.findings
.iter()
.filter(|f| !f.is_suppressed())
.count();
eprintln!(
"{name}: {remaining} finding(s) remain that --fix cannot resolve; lint `{name}` to see them"
);
}
}
}
}
}
if had_error {
ExitCode::from(2)
} else if gated {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn write_atomic(path: &str, contents: &str) -> std::io::Result<()> {
use std::io::ErrorKind;
let target = std::fs::canonicalize(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
let meta = std::fs::metadata(&target).ok();
if let Some(m) = &meta {
if m.permissions().readonly() {
return Err(std::io::Error::new(
ErrorKind::PermissionDenied,
"destination is read-only",
));
}
}
let mut tmp = target.clone().into_os_string();
tmp.push(format!(".pgsafe.{}.tmp", std::process::id()));
let tmp = std::path::PathBuf::from(tmp);
if let Err(e) = std::fs::write(&tmp, contents) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Some(m) = &meta {
let _ = std::fs::set_permissions(&tmp, m.permissions());
}
if let Err(e) = std::fs::rename(&tmp, &target) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
fn line_of(line_starts: &[usize], off: usize) -> usize {
match line_starts.binary_search(&off) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
const CONTEXT: usize = 3;
pub(super) fn render_diff(name: &str, original: &str, edits: &[FixEdit]) -> String {
if edits.is_empty() {
return String::new();
}
let mut line_starts = vec![0usize];
for (i, b) in original.bytes().enumerate() {
if b == b'\n' {
line_starts.push(i + 1);
}
}
let orig_lines: Vec<&str> = original.split_inclusive('\n').collect();
let total = orig_lines.len();
struct Block {
first: usize,
last: usize,
new_lines: Vec<String>,
}
struct Pending {
first: usize,
last: usize,
edits: Vec<FixEdit>,
}
let mut pending: Vec<Pending> = Vec::new();
for e in edits {
let start_line = line_of(&line_starts, e.start as usize);
let end_byte = (e.end as usize).max(e.start as usize + 1) - 1;
let end_line = line_of(&line_starts, end_byte);
match pending.last_mut() {
Some(p) if start_line <= p.last + 1 => {
p.last = p.last.max(end_line);
p.edits.push(e.clone());
}
_ => pending.push(Pending {
first: start_line,
last: end_line,
edits: vec![e.clone()],
}),
}
}
let blocks: Vec<Block> = pending
.into_iter()
.map(|p| {
let block_start = line_starts[p.first];
let block_end = line_starts
.get(p.last + 1)
.copied()
.unwrap_or(original.len());
let mut new_block = original[block_start..block_end].to_string();
let mut local: Vec<&FixEdit> = p.edits.iter().collect();
local.sort_by_key(|e| e.start);
for e in local.iter().rev() {
let s = e.start as usize - block_start;
let en = e.end as usize - block_start;
new_block.replace_range(s..en, &e.replacement);
}
Block {
first: p.first,
last: p.last,
new_lines: new_block
.split_inclusive('\n')
.map(str::to_string)
.collect(),
}
})
.collect();
let mut hunks: Vec<Vec<usize>> = Vec::new();
for (i, b) in blocks.iter().enumerate() {
match hunks.last_mut() {
Some(h) if b.first - blocks[h[h.len() - 1]].last - 1 <= 2 * CONTEXT => h.push(i),
_ => hunks.push(vec![i]),
}
}
let mut out = format!("--- a/{name}\n+++ b/{name}\n");
let mut new_delta: isize = 0;
for h in &hunks {
let region_first = blocks[h[0]].first;
let region_last = blocks[h[h.len() - 1]].last;
debug_assert!(
region_last < total,
"region_last {region_last} out of bounds for {total} original lines"
);
let ctx_before = region_first.min(CONTEXT);
let ctx_after = (total - 1 - region_last).min(CONTEXT);
let hunk_old_first = region_first - ctx_before;
let mut body = String::new();
let mut old_count = 0usize; let mut new_count = 0usize;
for &line in &orig_lines[hunk_old_first..region_first] {
push_diff_line(&mut body, ' ', line);
old_count += 1;
new_count += 1;
}
for (j, &bi) in h.iter().enumerate() {
let b = &blocks[bi];
for &line in &orig_lines[b.first..=b.last] {
push_diff_line(&mut body, '-', line);
old_count += 1;
}
for nl in &b.new_lines {
push_diff_line(&mut body, '+', nl);
new_count += 1;
}
if let Some(&next_bi) = h.get(j + 1) {
for &line in &orig_lines[(b.last + 1)..blocks[next_bi].first] {
push_diff_line(&mut body, ' ', line);
old_count += 1;
new_count += 1;
}
}
}
for &line in &orig_lines[(region_last + 1)..(region_last + 1 + ctx_after)] {
push_diff_line(&mut body, ' ', line);
old_count += 1;
new_count += 1;
}
let old_start = hunk_old_first + 1; let new_start =
usize::try_from(isize::try_from(old_start).unwrap_or(1) + new_delta).unwrap_or(1);
out.push_str(&format!(
"@@ -{old_start},{old_count} +{new_start},{new_count} @@\n"
));
out.push_str(&body);
new_delta +=
isize::try_from(new_count).unwrap_or(0) - isize::try_from(old_count).unwrap_or(0);
}
out
}
fn render_diff_strings(name: &str, original: &str, updated: &str) -> String {
if original == updated {
return String::new();
}
let a: Vec<&str> = original.split_inclusive('\n').collect();
let b: Vec<&str> = updated.split_inclusive('\n').collect();
let mut pre = 0usize;
while pre < a.len() && pre < b.len() && a[pre] == b[pre] {
pre += 1;
}
let mut suf = 0usize;
while suf < a.len() - pre && suf < b.len() - pre && a[a.len() - 1 - suf] == b[b.len() - 1 - suf]
{
suf += 1;
}
let a_first = pre;
let a_last_excl = a.len() - suf; let b_first = pre;
let b_last_excl = b.len() - suf;
let ctx_before = pre.min(CONTEXT);
let ctx_after = suf.min(CONTEXT);
let old_start = a_first - ctx_before + 1; let new_start = b_first - ctx_before + 1;
let old_count = ctx_before + (a_last_excl - a_first) + ctx_after;
let new_count = ctx_before + (b_last_excl - b_first) + ctx_after;
let mut out = format!("--- a/{name}\n+++ b/{name}\n");
out.push_str(&format!(
"@@ -{old_start},{old_count} +{new_start},{new_count} @@\n"
));
for &line in &a[a_first - ctx_before..a_first] {
push_diff_line(&mut out, ' ', line);
}
for &line in &a[a_first..a_last_excl] {
push_diff_line(&mut out, '-', line);
}
for &line in &b[b_first..b_last_excl] {
push_diff_line(&mut out, '+', line);
}
for &line in &a[a_last_excl..a_last_excl + ctx_after] {
push_diff_line(&mut out, ' ', line);
}
out
}
fn push_diff_line(out: &mut String, prefix: char, content: &str) {
out.push(prefix);
out.push_str(content);
if !content.ends_with('\n') {
out.push('\n');
out.push_str("\\ No newline at end of file\n");
}
}
const MAX_FIX_ITERATIONS: usize = 10;
enum Withheld {
ParseBroke(String),
NewError,
}
enum Termination {
Converged,
Withheld(Withheld),
CapHit,
}
struct Fixpoint {
sql: String,
edits: Option<Vec<FixEdit>>,
applied: usize,
iterations: usize,
termination: Termination,
original_findings: Vec<Finding>,
final_report: FileReport,
unfixable: usize,
skipped_overlapping: usize,
}
fn fix_to_fixpoint(
original: &str,
lint: impl Fn(&str) -> FileReport,
max_iters: usize,
) -> Fixpoint {
let original_report = lint(original);
let original_findings = original_report.findings.clone();
if original_report.error.is_some() {
return Fixpoint {
sql: original.to_string(),
edits: None,
applied: 0,
iterations: 0,
termination: Termination::Converged,
original_findings,
final_report: original_report,
unfixable: 0,
skipped_overlapping: 0,
};
}
let mut current = original.to_string();
let mut report = original_report; let mut first_edits: Option<Vec<FixEdit>> = None;
let mut iterations = 0usize;
let mut applied_total = 0usize;
let mut skipped_overlapping = 0usize;
let mut termination: Option<Termination> = None;
for _ in 0..max_iters {
let fixes: Vec<&Fix> = report
.findings
.iter()
.filter(|f| !f.is_suppressed())
.filter_map(|f| f.fix.as_ref())
.collect();
if fixes.is_empty() {
termination = Some(Termination::Converged); break;
}
let step = crate::fix::apply_all(¤t, &fixes);
if step.sql == current {
skipped_overlapping = step.skipped_overlapping;
termination = Some(Termination::Converged);
break;
}
let candidate = lint(&step.sql);
if let Some(err) = &candidate.error {
termination = Some(Termination::Withheld(Withheld::ParseBroke(err.clone())));
break;
}
if introduces_new_error(&original_findings, &candidate.findings) {
termination = Some(Termination::Withheld(Withheld::NewError));
break;
}
current = step.sql;
iterations += 1;
applied_total += step.applied;
skipped_overlapping = step.skipped_overlapping;
first_edits = if iterations == 1 {
Some(step.edits)
} else {
None
};
report = candidate; }
let unfixable = report
.findings
.iter()
.filter(|f| !f.is_suppressed() && f.fix.is_none())
.count();
let termination = termination.unwrap_or_else(|| {
let still_fixable = report
.findings
.iter()
.any(|f| !f.is_suppressed() && f.fix.is_some());
if still_fixable {
Termination::CapHit
} else {
Termination::Converged
}
});
Fixpoint {
sql: current,
edits: first_edits,
applied: applied_total,
iterations,
termination,
original_findings,
final_report: report,
unfixable,
skipped_overlapping,
}
}
#[cfg(test)]
mod tests {
use super::render_diff;
use crate::{FileReport, Fix, FixEdit, Location, Severity};
#[test]
fn introduces_new_error_detects_a_new_error_rule() {
let mk = |rule: &str, sev| crate::Finding {
rule_id: rule.into(),
severity: sev,
message: String::new(),
guidance: String::new(),
statement_index: 0,
location: Location {
byte: 0,
line: 1,
column: 1,
},
snippet: String::new(),
suppression: None,
fix: None,
};
let before = vec![mk("add-index-non-concurrent", Severity::Error)];
let after = vec![mk("concurrently-in-transaction", Severity::Error)];
assert!(super::introduces_new_error(&before, &after));
assert!(!super::introduces_new_error(&before, &[]));
assert!(!super::introduces_new_error(&before, &before));
assert!(!super::introduces_new_error(
&before,
&[mk("x", Severity::Warning)]
));
}
#[test]
fn empty_edits_render_nothing() {
assert_eq!(render_diff("f.sql", "SELECT 1;\n", &[]), "");
}
#[test]
fn single_line_replacement_diff() {
let sql = "CREATE INDEX i ON t (c);\n";
let edits = vec![FixEdit {
start: 12,
end: 12,
replacement: " CONCURRENTLY".into(),
}];
let out = render_diff("f.sql", sql, &edits);
assert!(
out.starts_with("--- a/f.sql\n+++ b/f.sql\n"),
"headers must use git a/ b/ prefixes: {out}"
);
assert!(out.contains("@@ "), "must have a hunk header: {out}");
assert!(
out.contains("-CREATE INDEX i ON t (c);\n"),
"must remove the original line: {out}"
);
assert!(
out.contains("+CREATE INDEX CONCURRENTLY i ON t (c);\n"),
"must add the fixed line: {out}"
);
}
#[test]
fn newline_adding_replacement_grows_line_count() {
let sql = "CREATE INDEX i ON t (c);\n";
let edits = vec![FixEdit {
start: 0,
end: 0,
replacement: "SET lock_timeout = '5s';\n".into(),
}];
let out = render_diff("f.sql", sql, &edits);
assert!(out.starts_with("--- a/f.sql\n+++ b/f.sql\n"), "{out}");
assert!(out.contains("@@ "), "{out}");
assert!(out.contains("-CREATE INDEX i ON t (c);\n"), "{out}");
assert!(out.contains("+SET lock_timeout = '5s';\n"), "{out}");
assert!(out.contains("+CREATE INDEX i ON t (c);\n"), "{out}");
assert_eq!(
out.lines()
.filter(|l| l.starts_with('-') && !l.starts_with("---"))
.count(),
1
);
assert_eq!(
out.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count(),
2
);
}
#[test]
fn nearby_changes_coalesce_with_context() {
let sql = "CREATE INDEX i ON a (c);\nSELECT 1;\nSELECT 2;\nCREATE INDEX j ON b (c);\n";
let edits = vec![
FixEdit {
start: 0,
end: 0,
replacement: "SET lock_timeout = '5s';\n".into(),
},
FixEdit {
start: 57,
end: 57,
replacement: " CONCURRENTLY".into(),
},
];
let out = render_diff("f.sql", sql, &edits);
assert!(out.starts_with("--- a/f.sql\n+++ b/f.sql\n"), "{out}");
assert_eq!(
out.matches("@@ ").count(),
1,
"expected one coalesced hunk: {out}"
);
assert!(out.contains(" SELECT 1;\n"), "context line missing: {out}");
assert!(out.contains(" SELECT 2;\n"), "context line missing: {out}");
assert!(out.contains("+SET lock_timeout = '5s';\n"), "{out}");
assert!(
out.contains("+CREATE INDEX CONCURRENTLY j ON b (c);\n"),
"{out}"
);
}
#[test]
fn no_trailing_newline_emits_marker() {
let sql = "CREATE INDEX i ON t (c);";
let edits = vec![FixEdit {
start: 12,
end: 12,
replacement: " CONCURRENTLY".into(),
}];
let out = render_diff("f.sql", sql, &edits);
assert!(
out.contains("\\ No newline at end of file"),
"must mark the missing trailing newline: {out}"
);
assert_eq!(
out.matches("\\ No newline at end of file").count(),
2,
"one marker per side: {out}"
);
}
fn finding_with_fix(rule: &str, sev: Severity, fix: Option<Fix>) -> crate::Finding {
crate::Finding {
rule_id: rule.into(),
severity: sev,
message: String::new(),
guidance: String::new(),
statement_index: 0,
location: Location {
byte: 0,
line: 1,
column: 1,
},
snippet: String::new(),
suppression: None,
fix,
}
}
fn report_with(findings: Vec<crate::Finding>) -> FileReport {
FileReport {
name: "t".into(),
findings,
error: None,
}
}
fn swap_first_char(to: &str) -> Fix {
Fix {
title: "swap".into(),
edits: vec![FixEdit {
start: 0,
end: 1,
replacement: to.into(),
}],
}
}
#[test]
fn fixpoint_cascades_until_no_fix() {
let lint = |s: &str| match s {
"1" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("2")),
)]),
"2" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("3")),
)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("1", lint, super::MAX_FIX_ITERATIONS);
assert_eq!(fp.sql, "3");
assert_eq!(fp.iterations, 2);
assert!(matches!(fp.termination, super::Termination::Converged));
assert_eq!(fp.applied, 2);
assert!(fp.edits.is_none());
}
#[test]
fn fixpoint_single_pass_keeps_original_edits() {
let lint = |s: &str| match s {
"1" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("2")),
)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("1", lint, super::MAX_FIX_ITERATIONS);
assert_eq!(fp.sql, "2");
assert_eq!(fp.iterations, 1);
assert!(matches!(fp.termination, super::Termination::Converged));
assert_eq!(
fp.edits.as_deref(),
Some(
&[FixEdit {
start: 0,
end: 1,
replacement: "2".into()
}][..]
)
);
}
#[test]
fn fixpoint_no_fixes_is_a_noop() {
let lint = |_: &str| report_with(vec![]);
let fp = super::fix_to_fixpoint("SELECT 1;", lint, super::MAX_FIX_ITERATIONS);
assert_eq!(fp.sql, "SELECT 1;");
assert_eq!(fp.iterations, 0);
assert!(matches!(fp.termination, super::Termination::Converged));
assert!(fp.edits.is_none());
assert_eq!(fp.applied, 0);
}
#[test]
fn fixpoint_oscillation_hits_cap() {
let lint = |s: &str| match s {
"a" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("b")),
)]),
"b" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("a")),
)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("a", lint, 4);
assert!(matches!(fp.termination, super::Termination::CapHit));
assert_eq!(fp.iterations, 4);
assert!(fp.sql == "a" || fp.sql == "b");
}
#[test]
fn fixpoint_backs_off_new_error_on_second_pass() {
let lint = |s: &str| match s {
"1" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("2")),
)]),
"2" => report_with(vec![
finding_with_fix("r", Severity::Warning, Some(swap_first_char("3"))),
]),
"3" => report_with(vec![finding_with_fix("boom", Severity::Error, None)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("1", lint, super::MAX_FIX_ITERATIONS);
assert_eq!(fp.sql, "2"); assert_eq!(fp.iterations, 1);
assert!(matches!(
fp.termination,
super::Termination::Withheld(super::Withheld::NewError)
));
}
#[test]
fn fixpoint_backs_off_on_first_pass_leaves_original() {
let lint = |s: &str| match s {
"1" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("2")),
)]),
_ => report_with(vec![finding_with_fix("boom", Severity::Error, None)]),
};
let fp = super::fix_to_fixpoint("1", lint, super::MAX_FIX_ITERATIONS);
assert_eq!(fp.sql, "1");
assert_eq!(fp.iterations, 0);
assert!(matches!(
fp.termination,
super::Termination::Withheld(super::Withheld::NewError)
));
assert!(fp.edits.is_none());
}
#[test]
fn fixpoint_backs_off_parse_break() {
let lint = |s: &str| match s {
"ok" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("X")),
)]),
_ => FileReport {
name: "t".into(),
findings: vec![],
error: Some("boom".into()),
},
};
let fp = super::fix_to_fixpoint("ok", lint, super::MAX_FIX_ITERATIONS);
assert_eq!(fp.sql, "ok");
assert!(matches!(
fp.termination,
super::Termination::Withheld(super::Withheld::ParseBroke(_))
));
}
#[test]
fn fixpoint_cap_reached_exactly_at_convergence() {
let lint = |s: &str| match s {
"1" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("2")),
)]),
"2" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("3")),
)]),
"3" => report_with(vec![finding_with_fix(
"r",
Severity::Warning,
Some(swap_first_char("4")),
)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("1", lint, 3);
assert!(
matches!(fp.termination, super::Termination::Converged),
"reached the fixpoint exactly at the cap"
);
assert_eq!(fp.sql, "4");
assert_eq!(fp.iterations, 3);
}
#[test]
fn fixpoint_recovers_skipped_overlap_on_next_pass() {
let lint = |s: &str| match s {
"ab" => report_with(vec![
finding_with_fix("winner", Severity::Warning, Some(swap_first_char("X"))),
finding_with_fix(
"loser",
Severity::Warning,
Some(Fix {
title: "loser".into(),
edits: vec![FixEdit {
start: 0,
end: 2,
replacement: "?".into(),
}],
}),
),
]),
"Xb" => report_with(vec![finding_with_fix(
"loser",
Severity::Warning,
Some(Fix {
title: "loser".into(),
edits: vec![FixEdit {
start: 1,
end: 2,
replacement: "Y".into(),
}],
}),
)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("ab", lint, super::MAX_FIX_ITERATIONS);
assert!(matches!(fp.termination, super::Termination::Converged));
assert_eq!(fp.sql, "XY"); assert_eq!(fp.applied, 2);
assert_eq!(fp.skipped_overlapping, 0); }
#[test]
fn fixpoint_no_textual_progress_converges() {
let lint = |s: &str| match s {
"x" => report_with(vec![finding_with_fix(
"noop",
Severity::Warning,
Some(swap_first_char("x")),
)]),
_ => report_with(vec![]),
};
let fp = super::fix_to_fixpoint("x", lint, super::MAX_FIX_ITERATIONS);
assert!(matches!(fp.termination, super::Termination::Converged));
assert_eq!(fp.iterations, 0);
assert_eq!(fp.sql, "x");
}
#[test]
fn render_diff_strings_single_hunk_between_common_context() {
let a = "keep 1;\nOLD a;\nOLD b;\nkeep 2;\n";
let b = "keep 1;\nNEW a;\nkeep 2;\n";
let out = super::render_diff_strings("f.sql", a, b);
assert!(out.starts_with("--- a/f.sql\n+++ b/f.sql\n"), "{out}");
assert!(out.contains("@@ "), "{out}");
assert!(out.contains(" keep 1;\n"), "leading context: {out}");
assert!(out.contains(" keep 2;\n"), "trailing context: {out}");
assert!(
out.contains("-OLD a;\n") && out.contains("-OLD b;\n"),
"removed lines: {out}"
);
assert!(out.contains("+NEW a;\n"), "added line: {out}");
}
#[test]
fn render_diff_strings_equal_is_empty() {
assert_eq!(super::render_diff_strings("f.sql", "x;\n", "x;\n"), "");
}
#[test]
fn render_diff_strings_change_on_first_line() {
let a = "OLD;\nkeep;\n";
let b = "NEW;\nkeep;\n";
let out = super::render_diff_strings("f.sql", a, b);
assert!(out.contains("@@ -1,2 +1,2 @@\n"), "{out}");
assert!(out.contains("-OLD;\n"), "{out}");
assert!(out.contains("+NEW;\n"), "{out}");
assert!(out.contains(" keep;\n"), "trailing context: {out}");
assert!(
out.starts_with("--- a/f.sql\n+++ b/f.sql\n@@ -1,2 +1,2 @@\n-OLD;\n"),
"no leading context before a first-line change: {out}"
);
}
#[test]
fn render_diff_strings_change_reaching_last_line() {
let a = "keep;\nOLD;\n";
let b = "keep;\nNEW;\n";
let out = super::render_diff_strings("f.sql", a, b);
assert!(out.contains("@@ -1,2 +1,2 @@\n"), "{out}");
assert!(out.contains(" keep;\n"), "leading context: {out}");
assert!(out.contains("-OLD;\n"), "{out}");
assert!(out.contains("+NEW;\n"), "{out}");
assert!(
out.ends_with("+NEW;\n"),
"no trailing context after a last-line change: {out}"
);
}
#[test]
fn real_inputs_converge_in_one_pass() {
use crate::LintOptions;
let opts = LintOptions::default();
let inputs = [
"CREATE INDEX i ON t (c);",
"ALTER TABLE t ADD COLUMN c json;",
"ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (a) REFERENCES u (id);",
"REINDEX INDEX i;",
"DROP INDEX i;",
"CREATE INDEX i ON t (c);\nALTER TABLE t ADD COLUMN c json;",
];
for sql in inputs {
let lint = |s: &str| crate::lint_input("<test>", s, &opts);
let fp = super::fix_to_fixpoint(sql, lint, super::MAX_FIX_ITERATIONS);
assert!(
fp.iterations <= 1,
"input unexpectedly cascaded ({} passes): {sql:?}",
fp.iterations
);
assert!(
matches!(fp.termination, super::Termination::Converged),
"input did not converge: {sql:?}"
);
if fp.iterations == 1 {
let report = crate::lint_input("<test>", sql, &opts);
let fixes: Vec<&crate::Fix> = report
.findings
.iter()
.filter(|f| !f.is_suppressed())
.filter_map(|f| f.fix.as_ref())
.collect();
let once = crate::fix::apply_all(sql, &fixes);
assert_eq!(
fp.sql, once.sql,
"fixpoint diverged from single-pass for {sql:?}"
);
}
}
}
}