use crate::{Fix, FixEdit};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FixAnchor {
Absolute { start: u32, end: u32 },
#[allow(dead_code)] StatementStart,
StatementBodyEnd,
AfterKeyword(&'static str),
ReplaceTokenAt(u32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FixDraftEdit {
pub anchor: FixAnchor,
pub replacement: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FixDraft {
pub title: &'static str,
pub edits: Vec<FixDraftEdit>,
}
impl FixDraft {
pub(crate) fn may_legitimately_not_resolve(&self) -> bool {
self.edits
.iter()
.any(|e| matches!(e.anchor, FixAnchor::ReplaceTokenAt(_)))
}
}
pub(crate) fn resolve(draft: &FixDraft, sql: &str, start: usize, end: usize) -> Option<Fix> {
if draft.edits.is_empty() {
return None;
}
let mut edits = Vec::with_capacity(draft.edits.len());
for e in &draft.edits {
let (s, en) = match e.anchor {
FixAnchor::Absolute { start, end } => {
let (s, e) = (start as usize, end as usize);
sql.get(s..e)?;
(s, e)
}
FixAnchor::StatementStart => (start, start),
FixAnchor::StatementBodyEnd => (end, end),
FixAnchor::AfterKeyword(kw) => {
let at = keyword_end(sql.get(start..end)?, kw)? + start;
(at, at)
}
FixAnchor::ReplaceTokenAt(at) => {
let at = at as usize;
let tok = token_len(sql.get(at..)?)?;
if sql
.get(at + tok..)
.is_some_and(|s| s.trim_start().starts_with('.'))
{
return None;
}
(at, at + tok)
}
};
edits.push(FixEdit {
start: u32::try_from(s).ok()?,
end: u32::try_from(en).ok()?,
replacement: e.replacement.clone(),
});
}
edits.sort_by_key(|e| e.start);
for w in edits.windows(2) {
debug_assert!(
w[0].end <= w[1].start,
"resolve produced overlapping edits: prev.end={} > next.start={}",
w[0].end,
w[1].start
);
}
Some(Fix {
title: draft.title.to_string(),
edits,
})
}
fn keyword_end(hay: &str, kw: &str) -> Option<usize> {
let (hl, kl) = (hay.as_bytes(), kw.as_bytes());
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let mut i = 0;
while i + kl.len() <= hl.len() {
if hl[i..i + kl.len()].eq_ignore_ascii_case(kl)
&& (i == 0 || !is_word(hl[i - 1]))
&& (i + kl.len() == hl.len() || !is_word(hl[i + kl.len()]))
{
return Some(i + kl.len());
}
i += 1;
}
None
}
fn token_len(s: &str) -> Option<usize> {
let n = s
.bytes()
.take_while(|b| b.is_ascii_alphanumeric() || *b == b'_')
.count();
(n > 0).then_some(n)
}
pub(crate) fn apply(sql: &str, edits: &[FixEdit]) -> String {
let mut out = sql.to_string();
let mut edits = edits.to_vec();
edits.sort_by_key(|e| std::cmp::Reverse(e.start));
for e in edits {
out.replace_range(e.start as usize..e.end as usize, &e.replacement);
}
out
}
pub(crate) struct Applied {
pub sql: String,
pub edits: Vec<FixEdit>,
pub applied: usize,
pub skipped_overlapping: usize,
}
pub(crate) fn apply_all(sql: &str, fixes: &[&Fix]) -> Applied {
let mut accepted: Vec<FixEdit> = Vec::new();
let mut applied = 0usize;
let mut skipped_overlapping = 0usize;
for fix in fixes {
let overlaps = fix
.edits
.iter()
.any(|e| accepted.iter().any(|a| e.start < a.end && a.start < e.end));
if overlaps {
skipped_overlapping += 1;
continue;
}
accepted.extend(fix.edits.iter().cloned());
applied += 1;
}
accepted.sort_by_key(|e| e.start);
debug_assert!(
accepted.windows(2).all(|w| w[0].end <= w[1].start),
"apply_all produced overlapping merged edits"
);
Applied {
sql: apply(sql, &accepted),
edits: accepted,
applied,
skipped_overlapping,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Fix, FixEdit};
const SQL: &str = "CREATE INDEX i ON t (c);";
#[test]
fn after_keyword_inserts_past_the_word() {
let d = FixDraft {
title: "Add CONCURRENTLY",
edits: vec![FixDraftEdit {
anchor: FixAnchor::AfterKeyword("INDEX"),
replacement: " CONCURRENTLY".into(),
}],
};
let fix = resolve(&d, SQL, 0, 23).unwrap();
assert_eq!(
fix.edits,
vec![FixEdit {
start: 12,
end: 12,
replacement: " CONCURRENTLY".into()
}]
);
assert_eq!(
apply(SQL, &fix.edits),
"CREATE INDEX CONCURRENTLY i ON t (c);"
);
}
#[test]
fn after_keyword_is_case_insensitive_and_word_bounded() {
let sql = "create index idx_index ON t (c);"; let d = FixDraft {
title: "Add CONCURRENTLY",
edits: vec![FixDraftEdit {
anchor: FixAnchor::AfterKeyword("INDEX"),
replacement: " CONCURRENTLY".into(),
}],
};
let fix = resolve(&d, sql, 0, sql.len() - 1).unwrap();
assert_eq!(
apply(sql, &fix.edits),
"create index CONCURRENTLY idx_index ON t (c);"
);
}
#[test]
fn after_keyword_absent_resolves_to_none() {
let d = FixDraft {
title: "x",
edits: vec![FixDraftEdit {
anchor: FixAnchor::AfterKeyword("MATERIALIZED"),
replacement: " z".into(),
}],
};
assert!(resolve(&d, SQL, 0, 23).is_none());
}
#[test]
fn statement_body_end_inserts_before_semicolon() {
let d = FixDraft {
title: "Add NOT VALID",
edits: vec![FixDraftEdit {
anchor: FixAnchor::StatementBodyEnd,
replacement: " NOT VALID".into(),
}],
};
let fix = resolve(&d, SQL, 0, 23).unwrap();
assert_eq!(
fix.edits,
vec![FixEdit {
start: 23,
end: 23,
replacement: " NOT VALID".into()
}]
);
assert_eq!(apply(SQL, &fix.edits), "CREATE INDEX i ON t (c) NOT VALID;");
}
#[test]
fn statement_start_inserts_a_prologue() {
let d = FixDraft {
title: "Set lock_timeout",
edits: vec![FixDraftEdit {
anchor: FixAnchor::StatementStart,
replacement: "SET lock_timeout = '5s';\n".into(),
}],
};
let fix = resolve(&d, SQL, 0, 23).unwrap();
assert_eq!(
apply(SQL, &fix.edits),
"SET lock_timeout = '5s';\nCREATE INDEX i ON t (c);"
);
}
#[test]
fn replace_token_at_swaps_the_identifier() {
let sql = "ALTER TABLE t ADD COLUMN c json;"; let d = FixDraft {
title: "Use jsonb",
edits: vec![FixDraftEdit {
anchor: FixAnchor::ReplaceTokenAt(27),
replacement: "jsonb".into(),
}],
};
let fix = resolve(&d, sql, 0, sql.len() - 1).unwrap();
assert_eq!(
fix.edits,
vec![FixEdit {
start: 27,
end: 31,
replacement: "jsonb".into()
}]
);
assert_eq!(apply(sql, &fix.edits), "ALTER TABLE t ADD COLUMN c jsonb;");
}
#[test]
fn apply_handles_multiple_edits_high_to_low() {
let fix = Fix {
title: "t".into(),
edits: vec![
FixEdit {
start: 0,
end: 0,
replacement: "A".into(),
},
FixEdit {
start: 3,
end: 3,
replacement: "B".into(),
},
],
};
assert_eq!(apply("xyz", &fix.edits), "AxyzB");
}
#[test]
fn after_keyword_offsets_are_byte_correct_past_multibyte() {
let sql = "-- é\nCREATE INDEX i ON t (c);";
let stmt_start = sql.find("CREATE").unwrap();
let d = FixDraft {
title: "Add CONCURRENTLY",
edits: vec![FixDraftEdit {
anchor: FixAnchor::AfterKeyword("INDEX"),
replacement: " CONCURRENTLY".into(),
}],
};
let fix = resolve(&d, sql, stmt_start, sql.len() - 1).unwrap();
assert_eq!(
apply(sql, &fix.edits),
"-- é\nCREATE INDEX CONCURRENTLY i ON t (c);"
);
}
#[test]
fn replace_token_drafts_may_not_resolve() {
let d = FixDraft {
title: "t",
edits: vec![FixDraftEdit {
anchor: FixAnchor::ReplaceTokenAt(0),
replacement: "x".into(),
}],
};
assert!(d.may_legitimately_not_resolve());
let k = FixDraft {
title: "t",
edits: vec![FixDraftEdit {
anchor: FixAnchor::AfterKeyword("INDEX"),
replacement: " y".into(),
}],
};
assert!(!k.may_legitimately_not_resolve());
}
#[test]
fn replace_token_at_suppresses_schema_qualifier() {
let sql = "pg_catalog.json";
let d = FixDraft {
title: "Use jsonb",
edits: vec![FixDraftEdit {
anchor: FixAnchor::ReplaceTokenAt(0),
replacement: "jsonb".into(),
}],
};
assert!(resolve(&d, sql, 0, sql.len()).is_none());
}
#[test]
fn resolve_rejects_empty_draft() {
let d = FixDraft {
title: "nothing",
edits: vec![],
};
assert!(resolve(&d, SQL, 0, 23).is_none());
}
#[test]
fn absolute_out_of_range_resolves_to_none() {
let d = FixDraft {
title: "x",
edits: vec![FixDraftEdit {
anchor: FixAnchor::Absolute {
start: 999,
end: 1000,
},
replacement: "z".into(),
}],
};
assert!(resolve(&d, SQL, 0, 23).is_none());
}
#[test]
fn multi_edit_descending_apply_order_preserves_second_span() {
let sql = "ALTER TABLE foo ADD COLUMN bar bigint;";
let fix = Fix {
title: "t".into(),
edits: vec![
FixEdit {
start: 12,
end: 15,
replacement: "renamed_table".into(),
},
FixEdit {
start: 31,
end: 37,
replacement: "int4".into(),
},
],
};
assert_eq!(
apply(sql, &fix.edits),
"ALTER TABLE renamed_table ADD COLUMN bar int4;"
);
}
#[test]
fn apply_all_composes_two_nonoverlapping_fixes() {
let sql = "ALTER TABLE t ADD COLUMN c json;";
let f1 = Fix {
title: "Use jsonb".into(),
edits: vec![FixEdit {
start: 27,
end: 31,
replacement: "jsonb".into(),
}],
};
let f2 = Fix {
title: "rename".into(),
edits: vec![FixEdit {
start: 12,
end: 13,
replacement: "tbl".into(),
}],
};
let out = apply_all(sql, &[&f1, &f2]);
assert_eq!(out.sql, "ALTER TABLE tbl ADD COLUMN c jsonb;");
assert_eq!(out.applied, 2);
assert_eq!(out.skipped_overlapping, 0);
assert_eq!(
out.edits.iter().map(|e| e.start).collect::<Vec<_>>(),
vec![12, 27]
);
}
#[test]
fn apply_all_skips_a_fix_overlapping_an_accepted_edit() {
let sql = "ALTER TABLE t ADD COLUMN c json;";
let first = Fix {
title: "Use jsonb".into(),
edits: vec![FixEdit {
start: 27,
end: 31,
replacement: "jsonb".into(),
}],
};
let clash = Fix {
title: "clash".into(),
edits: vec![FixEdit {
start: 29,
end: 31,
replacement: "X".into(),
}],
};
let out = apply_all(sql, &[&first, &clash]);
assert_eq!(out.sql, "ALTER TABLE t ADD COLUMN c jsonb;");
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_overlapping, 1);
}
#[test]
fn apply_all_empty_is_unchanged() {
let out = apply_all("SELECT 1;", &[]);
assert_eq!(out.sql, "SELECT 1;");
assert_eq!(out.applied, 0);
assert_eq!(out.skipped_overlapping, 0);
assert!(out.edits.is_empty());
}
}