clankerdiff-ratatui 0.1.6

Embeddable Ratatui diff review widget
Documentation
use clankerdiff_core::{FileDiff, ViewMode};
use clankerdiff_ratatui::{DiffPreviewOptions, DiffPreviewState, page_color};
use clankerdiff_syntax::SyntaxHighlighter;
use clankerdiff_theme::{DiffTone, ReviewTheme};
use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    widgets::{Paragraph, Widget},
};
use std::{error::Error, sync::Arc};

#[test]
fn split_additions_fill_missing_left_side_and_empty_source_lines() -> Result<(), Box<dyn Error>> {
    assert_split_backgrounds(
        "",
        "let added = 1;\n\n",
        &[(None, Some(DiffTone::Added)); 2],
    )
}

#[test]
fn split_deletions_fill_missing_right_side_and_empty_source_lines() -> Result<(), Box<dyn Error>> {
    assert_split_backgrounds(
        "let removed = 1;\n\n",
        "",
        &[(Some(DiffTone::Removed), None); 2],
    )
}

#[test]
fn split_replacements_fill_unequal_sides() -> Result<(), Box<dyn Error>> {
    let short = "let value = 1;\n";
    let long = "let value = 2;\nlet extra = 3;\n";
    assert_split_backgrounds(
        short,
        long,
        &[
            (Some(DiffTone::Removed), Some(DiffTone::Added)),
            (None, Some(DiffTone::Added)),
        ],
    )?;
    assert_split_backgrounds(
        long,
        short,
        &[
            (Some(DiffTone::Removed), Some(DiffTone::Added)),
            (Some(DiffTone::Removed), None),
        ],
    )
}

#[test]
fn split_divider_does_not_inherit_parent_background() -> Result<(), Box<dyn Error>> {
    assert_split_backgrounds(
        "let value = 1;\n",
        "let value = 2;\n",
        &[(Some(DiffTone::Removed), Some(DiffTone::Added))],
    )
}

#[test]
fn tabs_use_source_relative_stops_and_invalidate_cached_rows() -> Result<(), Box<dyn Error>> {
    assert_eq!(DiffPreviewOptions::default().tab_width, 2);
    let mut state = DiffPreviewState::new(FileDiff::from_texts(
        "tabs.rs",
        "",
        "\tlet a = 1;\na\tb\n\tx\n    spaces\n",
    )?);
    let theme = ReviewTheme::default();
    let mut highlighter = SyntaxHighlighter::default();
    for width in [80, 120] {
        let mut previous = None;
        for (tab_width, indent, middle, wide) in [
            (2, "  ", "a b", "界  x"),
            (4, "    ", "a   b", "界  x"),
            (0, " ", "a b", "界 x"),
        ] {
            let options = DiffPreviewOptions {
                tab_width,
                include_hunk_headers: false,
                ..Default::default()
            };
            let rows = state.render(width, &theme, &mut highlighter, options);
            let rendered: Vec<_> = rows.iter().map(ToString::to_string).collect();
            for expected in [
                format!("+ {indent}let a = 1;"),
                format!("+ {middle}"),
                format!("+ {wide}"),
                "+     spaces".to_owned(),
            ] {
                assert!(
                    rendered.iter().any(|line| line.contains(&expected)),
                    "{expected:?}: {rendered:?}"
                );
            }
            assert!(rows.iter().all(|row| row.width() <= usize::from(width)));
            if let Some(previous) = previous {
                assert!(!Arc::ptr_eq(&previous, &rows));
            }
            let cached = state.render(width, &theme, &mut highlighter, options);
            assert!(Arc::ptr_eq(&rows, &cached));
            previous = Some(rows);
        }
    }
    Ok(())
}

#[test]
fn expanded_tabs_preserve_syntax_styles() -> Result<(), Box<dyn Error>> {
    let theme = ReviewTheme::default();
    let options = DiffPreviewOptions::default();
    let mut highlighter = SyntaxHighlighter::default();
    let mut actual = DiffPreviewState::new(FileDiff::from_texts("tabs.rs", "", "\tlet a = 1;\n")?);
    let mut expected =
        DiffPreviewState::new(FileDiff::from_texts("tabs.rs", "", "  let a = 1;\n")?);
    assert_eq!(
        actual.render(80, &theme, &mut highlighter, options),
        expected.render(80, &theme, &mut highlighter, options),
    );
    Ok(())
}

#[test]
fn previews_reuse_rows_and_invalidate_replaced_files() -> Result<(), Box<dyn Error>> {
    let file = FileDiff::from_texts("src/main.rs", "old\n", "new\n")?;
    let mut state = DiffPreviewState::new(file);
    let mut highlighter = SyntaxHighlighter::default();
    let theme = ReviewTheme::default();
    let options = DiffPreviewOptions::default();
    let first = state.render(80, &theme, &mut highlighter, options);
    let unchanged = state.render(80, &theme, &mut highlighter, options);
    assert!(Arc::ptr_eq(&first, &unchanged));
    assert_eq!(state.take_stats().cache_hits, 1);
    state.set_file(FileDiff::from_texts(
        "src/main.rs",
        "new\n",
        "replacement\n",
    )?);
    let replaced = state.render(80, &theme, &mut highlighter, options);
    assert!(!Arc::ptr_eq(&first, &replaced));
    assert!(
        replaced
            .iter()
            .any(|row| row.to_string().contains("replacement"))
    );
    Ok(())
}

#[test]
fn theme_changes_recolor_previews_without_parsing() -> Result<(), Box<dyn Error>> {
    let mut state = DiffPreviewState::new(FileDiff::from_texts(
        "src/main.rs",
        "let before = 1;\n",
        "let after = 2;\n",
    )?);
    let mut highlighter = SyntaxHighlighter::default();
    let options = DiffPreviewOptions::default();
    let first = state.render(80, &ReviewTheme::default(), &mut highlighter, options);
    assert!(highlighter.take_stats().bytes > 0);
    let changed = state.render(80, &ReviewTheme::ayu()?, &mut highlighter, options);
    assert_ne!(first, changed);
    let work = highlighter.take_stats();
    assert_eq!(work.bytes, 0);
    assert_eq!(work.misses, 0);
    assert!(work.hits > 0);
    Ok(())
}

#[test]
fn preview_rows_remain_bounded_at_tiny_and_split_widths() -> Result<(), Box<dyn Error>> {
    let mut state = DiffPreviewState::new(FileDiff::from_texts(
        "wide.rs",
        "界界界\te\u{301}\n",
        "changed\n",
    )?);
    for width in [0, 1, 2, 5, 10, 95, 96, 120] {
        let rows = state.render(
            width,
            &ReviewTheme::default(),
            &mut SyntaxHighlighter::default(),
            DiffPreviewOptions {
                view_mode: ViewMode::Auto,
                ..DiffPreviewOptions::default()
            },
        );
        assert!(
            rows.iter().all(|row| row.width() <= usize::from(width)),
            "width: {width}"
        );
    }
    Ok(())
}

fn assert_split_backgrounds(
    before: &str,
    after: &str,
    expected: &[(Option<DiffTone>, Option<DiffTone>)],
) -> Result<(), Box<dyn Error>> {
    let mut state = DiffPreviewState::new(FileDiff::from_texts("example.rs", before, after)?);
    let mut highlighter = SyntaxHighlighter::default();
    let options = DiffPreviewOptions {
        view_mode: ViewMode::Split,
        include_hunk_headers: false,
        overflow_summary: false,
        ..Default::default()
    };
    for width in [96, 97, 120, 121] {
        for theme in [ReviewTheme::default(), ReviewTheme::ayu()?] {
            let rows = state.render(width, &theme, &mut highlighter, options);
            assert_eq!(rows.len(), expected.len());
            let area = Rect::new(0, 0, width, u16::try_from(expected.len())?);
            let mut buffer = Buffer::empty(area);
            buffer.set_style(area, Style::new().bg(Color::Magenta));
            Paragraph::new(rows.to_vec()).render(area, &mut buffer);
            let divider = (width - 1) / 2;
            let background = page_color(&theme, theme.diff.background);
            for (y, &(left, right)) in expected.iter().enumerate() {
                let y = u16::try_from(y)?;
                for (range, tone) in [(0..divider, left), (divider + 1..width, right)] {
                    let expected_bg = tone.map_or(background, |tone| {
                        page_color(&theme, theme.diff.tone(tone).background)
                    });
                    for x in range {
                        assert_eq!(
                            buffer[(x, y)].bg,
                            expected_bg,
                            "width {width}, cell ({x}, {y}), tone {tone:?}"
                        );
                        if tone.is_none() {
                            assert_eq!(buffer[(x, y)].symbol(), " ");
                        }
                    }
                }
                assert_eq!(buffer[(divider, y)].symbol(), "");
                assert_eq!(buffer[(divider, y)].bg, background);
                assert_eq!(
                    buffer[(divider, y)].fg,
                    page_color(&theme, theme.diff.border)
                );
            }
        }
    }
    Ok(())
}