#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidatorPolicy {
Off,
Warn,
Block,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostEditDiagnostic {
pub kind: String,
pub message: String,
}
pub type FormatterHook<'a> = &'a dyn Fn(&str, &str) -> anyhow::Result<Option<String>>;
pub struct PostEditValidator<'a> {
pub name: &'a str,
pub policy: ValidatorPolicy,
pub check: &'a dyn Fn(&str, &str) -> Vec<PostEditDiagnostic>,
}
#[derive(Default)]
pub struct PostEditHooks<'a> {
pub formatter: Option<FormatterHook<'a>>,
pub validators: Vec<PostEditValidator<'a>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostEditOutcome {
pub content: String,
pub formatted: bool,
pub diagnostics: Vec<PostEditDiagnostic>,
pub blocked: bool,
}
pub fn run_post_edit_hooks(path: &str, content: &str, hooks: &PostEditHooks<'_>) -> PostEditOutcome {
let mut diagnostics = Vec::new();
let mut formatted = false;
let mut current = content.to_string();
if let Some(formatter) = hooks.formatter {
match formatter(path, ¤t) {
Ok(Some(next)) => {
formatted = next != current;
current = next;
}
Ok(None) => {}
Err(error) => diagnostics.push(PostEditDiagnostic {
kind: "formatter_failed".to_string(),
message: format!("formatter failed for {path}: {error}"),
}),
}
}
let mut blocked = false;
for validator in &hooks.validators {
if matches!(validator.policy, ValidatorPolicy::Off) {
continue;
}
let findings = (validator.check)(path, ¤t);
if findings.is_empty() {
continue;
}
if matches!(validator.policy, ValidatorPolicy::Block) {
blocked = true;
}
diagnostics.extend(findings.into_iter().map(|finding| PostEditDiagnostic {
kind: finding.kind,
message: format!("[{}] {}", validator.name, finding.message),
}));
}
PostEditOutcome {
content: current,
formatted,
diagnostics,
blocked,
}
}
pub fn normalize_inserted_indentation(old_string: &str, new_string: &str) -> String {
let Some(base_indent) = uniform_min_indent(old_string) else {
return new_string.to_string();
};
if base_indent.is_empty() {
return new_string.to_string();
}
match uniform_min_indent(new_string) {
Some(indent) if indent.is_empty() => {}
_ => return new_string.to_string(),
}
new_string
.split('\n')
.map(|line| {
if line.trim().is_empty() {
line.to_string()
} else {
format!("{base_indent}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn uniform_min_indent(text: &str) -> Option<String> {
let mut min_indent: Option<&str> = None;
for line in text.lines().filter(|line| !line.trim().is_empty()) {
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
if indent.chars().any(|ch| ch != ' ' && ch != '\t') {
return None;
}
min_indent = Some(match min_indent {
None => indent,
Some(current) => {
let (short, long) = if indent.len() <= current.len() {
(indent, current)
} else {
(current, indent)
};
if !long.starts_with(short) {
return None;
}
short
}
});
}
min_indent.map(str::to_string)
}