use std::process::ExitCode;
use super::ResolvedRun;
use crate::{gate, lint_input, Fix, FixEdit};
const STDIN_NAME: &str = "<stdin>";
pub(super) enum Mode {
Apply,
Diff,
}
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 report = lint_input(name.clone(), sql, &r.options_for(name));
if let Some(err) = &report.error {
eprintln!("error: {name}: {err}");
had_error = true;
continue;
}
let fixes: Vec<&Fix> = report
.findings
.iter()
.filter(|f| !f.is_suppressed())
.filter_map(|f| f.fix.as_ref())
.collect();
let unfixable = report
.findings
.iter()
.filter(|f| !f.is_suppressed() && f.fix.is_none())
.count();
let applied = crate::fix::apply_all(sql, &fixes);
match mode {
Mode::Diff => {
print!("{}", render_diff(name, sql, &applied.edits));
if gate(&report.findings, r.fail_on) {
gated = true;
let residual = unfixable + applied.skipped_overlapping;
if residual > 0 {
eprintln!(
"{name}: {residual} finding(s) have no automatic fix or were skipped (overlapping another fix); run `pgsafe {name}` to see them"
);
}
}
}
Mode::Apply => {
let after = lint_input(name.clone(), &applied.sql, &r.options_for(name));
if let Some(err) = &after.error {
eprintln!(
"error: {name}: applying fixes produced SQL that no longer parses ({err}); file left unchanged"
);
had_error = true;
continue;
}
let changed = applied.sql != *sql;
if name == STDIN_NAME {
print!("{}", applied.sql);
} else if changed {
if let Err(e) = write_atomic(name, &applied.sql) {
eprintln!("error: {name}: {e}");
had_error = true;
continue;
}
}
if changed {
let mut note = String::new();
if unfixable > 0 {
note.push_str(&format!("{unfixable} unfixable"));
}
if applied.skipped_overlapping > 0 {
if !note.is_empty() {
note.push_str(", ");
}
note.push_str(&format!(
"{} skipped-overlapping",
applied.skipped_overlapping
));
}
let suffix = if note.is_empty() {
String::new()
} else {
format!(" ({note})")
};
eprintln!("fixed {} findings in {name}{suffix}", applied.applied);
}
if gate(&after.findings, r.fail_on) {
gated = true;
if !changed {
let remaining =
after.findings.iter().filter(|f| !f.is_suppressed()).count();
eprintln!(
"{name}: {remaining} finding(s) remain that --fix cannot resolve; run `pgsafe {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 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");
}
}
#[cfg(test)]
mod tests {
use super::render_diff;
use crate::FixEdit;
#[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}"
);
}
}