clankerdiff-ratatui 0.1.6

Embeddable Ratatui diff review widget
Documentation
#![allow(missing_docs)]

mod support;

use clankerdiff_core::{DiffDocument, testing::DocumentBuilder};
use clankerdiff_ratatui::{KeyCode, MouseEventKind};
use clankerdiff_syntax::{Fingerprint, SyntaxError, SyntaxHighlighter, SyntaxTheme};
use clankerdiff_theme::ReviewTheme;
use std::{error::Error, fmt::Write, str, sync::Arc};
use support::{MarkdownStreamFixture, ReviewHarness, key, mouse};

#[test]
fn settled_frame_emits_no_terminal_cells_and_reuses_highlights() {
    let mut harness = ReviewHarness::new(large_document(1_000), 100, 24);
    let cold = harness.draw();
    assert!(cold.backend.cells_drawn > 0);
    assert!(cold.highlight_misses > 0);

    let settled = harness.draw();
    assert_eq!(settled.backend.cells_drawn, 0);
    assert_eq!(settled.highlight_misses, 0);
    assert_eq!(settled.highlight_calls, settled.highlight_hits);
}

#[test]
fn an_equal_content_snapshot_swap_reuses_every_highlight() {
    let old = source_lines(1_000, "");
    let new = source_lines(1_000, " + 1");
    let fixture = || {
        DocumentBuilder::new()
            .changed("src/large.rs", &old, &new)
            .build()
    };
    let mut harness = ReviewHarness::new(fixture(), 100, 24);
    harness.draw();
    assert_eq!(harness.draw().highlight_misses, 0);

    harness.state_mut().set_document(fixture());

    let swapped = harness.draw();
    assert_eq!(
        swapped.highlight_misses, 0,
        "content-addressed highlights must survive a snapshot swap"
    );
    assert_eq!(swapped.highlight_calls, swapped.highlight_hits);
}

#[test]
fn highlighting_work_is_bounded_by_the_viewport_not_document_size() {
    let mut small = ReviewHarness::new(large_document(1_000), 80, 20);
    let mut large = ReviewHarness::new(large_document(100_000), 80, 20);

    let small_frame = small.draw();
    let large_frame = large.draw();
    assert_eq!(small_frame.highlight_calls, large_frame.highlight_calls);
    assert_eq!(small_frame.highlight_misses, large_frame.highlight_misses);
    assert!(large_frame.highlight_calls <= 18);
    assert_eq!(small_frame.highlighted_bytes, large_frame.highlighted_bytes);
    assert!(
        large_frame.highlighted_bytes < 100_000,
        "highlighted {} bytes from a 100,000-line document",
        large_frame.highlighted_bytes
    );
}

#[test]
fn deep_full_file_scroll_parses_complete_sources_once() -> Result<(), SyntaxError> {
    let old = source_lines(100_000, "");
    let new = old.replacen(
        "let value_50000 = 50000;",
        "let value_50000 = 50000 + 1;",
        1,
    );
    let fixture = DocumentBuilder::new()
        .changed_with_hunk_window("src/large.rs", &old, &new, 49_997..=50_003)
        .build();
    let mut harness = ReviewHarness::new(fixture, 100, 24);
    harness.input(key(KeyCode::Tab));
    harness.input(key(KeyCode::Char('f')));
    let first_parse = harness.draw();
    assert!(
        first_parse.highlighted_bytes >= old.len(),
        "first visible complete side parsed {} bytes",
        first_parse.highlighted_bytes
    );
    let mut reference = SyntaxHighlighter::new(0);
    let theme = SyntaxTheme::default();
    for source in [&old, &new] {
        reference.with_theme(&theme).highlight_document(
            Fingerprint::of([source.as_str()]),
            "rust",
            || source,
        )?;
    }
    assert!(first_parse.highlight_misses <= 2);
    assert!(first_parse.highlighted_bytes <= reference.stats().bytes);
    assert!(reference.stats().bytes <= 4 * (old.len() + new.len()) + 64 * 1024);
    let jumped = harness.input_and_draw(key(KeyCode::End));
    assert!(
        jumped.highlighted_bytes < 100_000,
        "deep full-file navigation parsed {} bytes",
        jumped.highlighted_bytes
    );
    let settled = harness.draw();
    assert_eq!(settled.highlight_misses, 0);
    Ok(())
}

#[test]
fn deep_patch_only_split_scroll_highlights_visible_lines_and_then_settles() {
    let mut harness = ReviewHarness::new(modified_document(5_000), 100, 24);
    harness.draw();
    harness.input(key(KeyCode::Enter));
    for _ in 0..3 {
        if harness.state().layout().is_split() {
            break;
        }
        harness.input(key(KeyCode::Char('v')));
    }
    assert!(harness.state().layout().is_split());
    harness.draw();

    for _ in 0..60 {
        harness.input(key(KeyCode::PageDown));
    }
    let jumped = harness.draw();
    assert!(
        jumped.highlighted_bytes < 150_000,
        "a deep jump parses bounded context per side, parsed {} bytes",
        jumped.highlighted_bytes
    );

    let settled = harness.draw();
    assert_eq!(
        settled.highlight_misses, 0,
        "the current patch-only viewport stays cached"
    );
    let paged = harness.input_and_draw(key(KeyCode::PageDown));
    assert!(
        paged.highlight_misses <= 48,
        "patch-only fallback highlights at most the newly visible split cells"
    );
}

#[test]
fn moving_one_row_changes_only_a_bounded_screen_region() {
    const WIDTH: u16 = 100;
    let mut harness = ReviewHarness::new(large_document(10_000), WIDTH, 24);
    harness.draw();
    harness.input(key(KeyCode::Enter));
    harness.draw();

    let moved = harness.input_and_draw(key(KeyCode::Down));
    assert!(
        moved.backend.cells_drawn <= u64::from(WIDTH) * 3,
        "one-row navigation changed {} cells",
        moved.backend.cells_drawn
    );
    assert_eq!(moved.highlight_misses, 0);
}

#[test]
fn one_wheel_notch_changes_only_a_bounded_screen_region() {
    const WIDTH: u16 = 100;
    const PATCH_COLUMN: u16 = 60;
    let mut harness = ReviewHarness::new(large_document(10_000), WIDTH, 24);
    harness.draw();
    harness.input(key(KeyCode::Enter));
    harness.draw();

    let moved = harness.input_and_draw(mouse(MouseEventKind::ScrollDown, PATCH_COLUMN, 5));
    assert!(
        moved.backend.cells_drawn <= u64::from(WIDTH) * 3,
        "one wheel notch changed {} cells",
        moved.backend.cells_drawn
    );
    assert_eq!(moved.highlight_misses, 0);
}

#[test]
fn moving_back_to_a_cached_row_does_not_rehighlight_it() {
    let mut harness = ReviewHarness::new(large_document(1_000), 80, 20);
    harness.draw();
    harness.input(key(KeyCode::Enter));
    harness.draw();
    harness.input_and_draw(key(KeyCode::Down));

    let back = harness.input_and_draw(key(KeyCode::Up));
    assert_eq!(back.highlight_misses, 0);
    assert_eq!(back.highlight_calls, back.highlight_hits);
    assert!(harness.state().selected_row().is_some());
    assert!(!harness.buffer().content().is_empty());
}

#[test]
fn changing_the_review_theme_does_not_reparse_visible_sources() -> Result<(), Box<dyn Error>> {
    let mut harness = ReviewHarness::new(large_document(1_000), 100, 24);
    assert!(harness.draw().highlighted_bytes > 0);
    harness.state_mut().set_theme(ReviewTheme::ayu()?);
    let changed = harness.draw();
    assert_eq!(changed.highlighted_bytes, 0);
    assert_eq!(changed.highlight_misses, 0);
    assert!(changed.highlight_hits > 0);
    assert!(changed.backend.cells_drawn > 0);
    Ok(())
}

#[test]
fn aether_streaming_prose_has_bounded_parser_work() -> Result<(), Box<dyn Error>> {
    let mut source = String::new();
    let mut sentence = 0;
    while source.len() < 16 * 1024 {
        for _ in 0..4 {
            write!(
                source,
                "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. "
            )?;
            sentence += 1;
        }
        source.push_str("\n\n");
    }
    assert_streaming_budget(&source, false)
}

#[test]
fn aether_streaming_code_has_bounded_parser_and_highlight_work() -> Result<(), Box<dyn Error>> {
    let mut source = String::from("```rust\n");
    let mut line = 0;
    while source.len() < 24 * 1024 {
        writeln!(
            source,
            "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");"
        )?;
        line += 1;
    }
    source.push_str("```\n");
    assert_streaming_budget(&source, true)
}

fn assert_streaming_budget(source: &str, code: bool) -> Result<(), Box<dyn Error>> {
    let mut fixture = MarkdownStreamFixture::default();
    fixture.options.width = 120;
    for chunk in source.as_bytes().chunks(256) {
        fixture.stream.push(str::from_utf8(chunk)?);
        fixture.apply_update();
    }
    fixture.stream.finish();
    fixture.apply_update();
    let work = fixture.state.take_stats();
    let budget = 4 * source.len() + 64 * 1024;
    assert_eq!(work.rows_materialized, 0);
    assert!(
        work.parsed_bytes <= budget && (!code || work.highlighted_bytes <= budget),
        "{work:?}, budget {budget}"
    );
    fixture.assert_equivalent();
    fixture.state.take_stats();
    fixture.apply_update();
    let settled = fixture.state.take_stats();
    assert_eq!(settled.parsed_bytes, 0);
    assert_eq!(settled.highlighted_bytes, 0);
    assert_eq!(settled.rows_generated, 0);
    Ok(())
}

fn large_document(rows: usize) -> Arc<DiffDocument> {
    DocumentBuilder::new()
        .generated("src/large.rs", rows)
        .build()
}

fn modified_document(rows: usize) -> Arc<DiffDocument> {
    DocumentBuilder::new()
        .changed(
            "src/large.rs",
            &source_lines(rows, ""),
            &source_lines(rows, " + 1"),
        )
        .build()
}

fn source_lines(rows: usize, suffix: &str) -> String {
    (1..=rows).fold(String::new(), |mut source, i| {
        let _ = writeln!(source, "let value_{i} = {i}{suffix};");
        source
    })
}