use std::path::Path;
use crate::cli::DebugChecksArg;
use crate::formatter::{FormatStyle, format_with_style};
use crate::parser::{parse, reconstruct};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CheckKind {
Losslessness,
Idempotency,
FormatError,
}
impl CheckKind {
pub fn label(self) -> &'static str {
match self {
CheckKind::Losslessness => "losslessness",
CheckKind::Idempotency => "idempotency",
CheckKind::FormatError => "format-error",
}
}
}
pub struct DebugFailure {
pub kind: CheckKind,
pub left: String,
pub right: String,
}
#[derive(Default)]
pub struct DebugArtifacts {
pub losslessness: Option<(String, String)>,
pub idempotency: Option<(String, String, String)>,
pub failures: Vec<DebugFailure>,
}
pub fn checks_label(checks: DebugChecksArg) -> &'static str {
match checks {
DebugChecksArg::Idempotency => "idempotency",
DebugChecksArg::Losslessness => "losslessness",
DebugChecksArg::All => "all",
}
}
pub fn sanitize_path_for_filename(path: &str) -> String {
path.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect()
}
pub fn first_diff_line(left: &str, right: &str) -> usize {
let mut left_lines = left.lines();
let mut right_lines = right.lines();
let mut line = 1;
loop {
match (left_lines.next(), right_lines.next()) {
(Some(a), Some(b)) if a == b => line += 1,
_ => return line,
}
}
}
fn render_window_diff(left: &str, right: &str, out: &mut String) {
const CONTEXT: usize = 3;
const MAX_SIDE: usize = 40;
let start = first_diff_line(left, right) - 1;
let context_start = start.saturating_sub(CONTEXT);
for line in left.lines().skip(context_start).take(start - context_start) {
out.push(' ');
out.push_str(line);
out.push('\n');
}
for (side, text) in [('-', left), ('+', right)] {
let mut lines = text.lines().skip(start);
for line in lines.by_ref().take(MAX_SIDE) {
out.push(side);
out.push_str(line);
out.push('\n');
}
if lines.next().is_some() {
out.push(side);
out.push_str(" [truncated]\n");
}
}
}
pub fn build_debug_report(
checks: DebugChecksArg,
files_checked: usize,
failures: &[(String, DebugFailure)],
) -> String {
let mut out = String::new();
out.push_str("# Debug-format regression report\n\n");
out.push_str(&format!(
"- Checks: `{}`\n- Files checked: {files_checked}\n- Failures: {}\n\n",
checks_label(checks),
failures.len()
));
if failures.is_empty() {
out.push_str("All checks passed.\n");
return out;
}
out.push_str("## Failures\n\n");
for (idx, (file, failure)) in failures.iter().enumerate() {
out.push_str(&format!(
"### {}. `{}` ({})\n\n",
idx + 1,
file,
failure.kind.label()
));
if failure.kind == CheckKind::FormatError {
out.push_str(&format!("- Error: {}\n\n", failure.left));
continue;
}
out.push_str(&format!(
"- Approx. diff start line: {}\n\n",
first_diff_line(&failure.left, &failure.right)
));
out.push_str("```diff\n");
render_window_diff(&failure.left, &failure.right, &mut out);
out.push_str("```\n\n");
}
out
}
pub fn write_debug_artifacts(
dump_dir: &Path,
stem: &str,
artifacts: &DebugArtifacts,
dump_passes: bool,
) -> std::io::Result<()> {
std::fs::create_dir_all(dump_dir)?;
let failed = |kind: CheckKind| artifacts.failures.iter().any(|f| f.kind == kind);
if let Some((input, parsed)) = artifacts.losslessness.as_ref()
&& (dump_passes || failed(CheckKind::Losslessness))
{
std::fs::write(
dump_dir.join(format!("{stem}.losslessness.input.txt")),
input,
)?;
std::fs::write(
dump_dir.join(format!("{stem}.losslessness.parsed.txt")),
parsed,
)?;
}
if let Some((input, once, twice)) = artifacts.idempotency.as_ref()
&& (dump_passes || failed(CheckKind::Idempotency))
{
std::fs::write(
dump_dir.join(format!("{stem}.idempotency.input.txt")),
input,
)?;
std::fs::write(dump_dir.join(format!("{stem}.idempotency.once.txt")), once)?;
std::fs::write(
dump_dir.join(format!("{stem}.idempotency.twice.txt")),
twice,
)?;
}
for failure in &artifacts.failures {
let kind = failure.kind.label();
std::fs::write(
dump_dir.join(format!("{stem}.{kind}.left.txt")),
&failure.left,
)?;
std::fs::write(
dump_dir.join(format!("{stem}.{kind}.right.txt")),
&failure.right,
)?;
}
Ok(())
}
pub fn run_debug_checks_for_file(
content: &str,
style: FormatStyle,
checks: DebugChecksArg,
) -> DebugArtifacts {
let mut artifacts = DebugArtifacts::default();
if matches!(checks, DebugChecksArg::Losslessness | DebugChecksArg::All) {
let reconstructed = reconstruct(content);
artifacts.losslessness = Some((content.to_string(), reconstructed.clone()));
if reconstructed != content {
artifacts.failures.push(DebugFailure {
kind: CheckKind::Losslessness,
left: content.to_string(),
right: reconstructed,
});
}
}
if matches!(checks, DebugChecksArg::Idempotency | DebugChecksArg::All) {
let diagnostics = parse(content).diagnostics;
if let Some(first) = diagnostics.first() {
artifacts.failures.push(DebugFailure {
kind: CheckKind::FormatError,
left: format!(
"input has {} parse diagnostic(s); first at [{}..{}]: {}",
diagnostics.len(),
first.start,
first.end,
first.message
),
right: String::new(),
});
return artifacts;
}
match format_with_style(content, style) {
Err(err) => artifacts.failures.push(DebugFailure {
kind: CheckKind::FormatError,
left: err.to_string(),
right: String::new(),
}),
Ok(once) => {
let once_diags = parse(&once).diagnostics;
if let Some(first) = once_diags.first() {
artifacts.idempotency =
Some((content.to_string(), once.clone(), String::new()));
artifacts.failures.push(DebugFailure {
kind: CheckKind::Idempotency,
left: once,
right: format!(
"formatted output does not reparse cleanly: [{}..{}]: {}",
first.start, first.end, first.message
),
});
return artifacts;
}
match format_with_style(&once, style) {
Ok(twice) => {
artifacts.idempotency =
Some((content.to_string(), once.clone(), twice.clone()));
if once != twice {
artifacts.failures.push(DebugFailure {
kind: CheckKind::Idempotency,
left: once,
right: twice,
});
}
}
Err(err) => {
artifacts.idempotency =
Some((content.to_string(), once.clone(), String::new()));
artifacts.failures.push(DebugFailure {
kind: CheckKind::Idempotency,
left: once,
right: format!("second pass failed to format: {err}"),
});
}
}
}
}
}
artifacts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_matches_the_workflow_sed_class() {
assert_eq!(
sanitize_path_for_filename("sub dir/a b.jl"),
"sub_dir_a_b.jl"
);
assert_eq!(sanitize_path_for_filename("ok-1.2_x.jl"), "ok-1.2_x.jl");
assert_eq!(sanitize_path_for_filename("å/ü.jl"), "___.jl");
}
#[test]
fn first_diff_line_finds_the_first_mismatch() {
assert_eq!(first_diff_line("a\nb\nc\n", "a\nB\nc\n"), 2);
assert_eq!(first_diff_line("a\n", "a\nb\n"), 2);
assert_eq!(first_diff_line("same\n", "same\n"), 2);
}
#[test]
fn parse_diagnostics_bucket_as_format_error_only() {
let artifacts =
run_debug_checks_for_file("f(x\n", FormatStyle::default(), DebugChecksArg::All);
let labels: Vec<_> = artifacts.failures.iter().map(|f| f.kind.label()).collect();
assert_eq!(labels, ["format-error"]);
}
}