diffler 0.20.0

Standalone magit-style git TUI and code reviewer, on its own or with your coding agent
Documentation
//! Background enrichment of diff files: intra-line emphasis, whole-file
//! syntax highlight, and the scope index cost hundreds of ms on large files,
//! so we run them on the blocking pool and the pane renders plain until the
//! result arrives as an event.

use diffler_core::diffalgo::DiffAlgorithm;
use diffler_core::highlight::Highlighter;
use diffler_core::model::{BlobIds, FileDiff, HashCache, Hunk};
use diffler_core::pairing;

use super::App;
use super::diff::{FileHighlights, FileScope};

#[derive(Debug)]
pub struct EnrichJob {
    pub path: String,
    pub hash: String,
    pub old_text: Option<String>,
    pub new_text: Option<String>,
    pub hunks: Vec<Hunk>,
    pub semantic: bool,
    pub stamp: EnrichStamp,
}

/// What an enrichment is computed under. The file's hash cannot tell two
/// algorithms' hunks or two highlighters' colours apart, so we drop an
/// outcome whose stamp no longer matches the app's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EnrichStamp {
    /// The algorithm that produced the job's hunks.
    pub algorithm: DiffAlgorithm,
    /// Which of the app's highlighters ran it, counted up on every rebuild.
    pub highlighter: u64,
}

#[derive(Debug)]
pub struct EnrichOutcome {
    pub path: String,
    pub hash: String,
    pub hunks: Vec<Hunk>,
    pub highlights: FileHighlights,
    pub scope: FileScope,
    pub stamp: EnrichStamp,
}

/// Queue `file` for enrichment unless it is `ready` or a job for the same
/// content is in flight. We take the two collections, so a caller looping over
/// another borrowed `App` field can still call it.
pub(super) fn queue_if_stale(
    inflight: &mut std::collections::HashSet<String>,
    pending: &mut Vec<EnrichJob>,
    file: &FileDiff,
    semantic: bool,
    stamp: EnrichStamp,
    ready: bool,
) {
    if ready {
        return;
    }
    let hash = file.sides_hash();
    if !inflight.insert(hash.clone()) {
        return;
    }
    pending.push(EnrichJob {
        path: file.path.clone(),
        hash,
        old_text: file.old_text.clone(),
        new_text: file.new_text.clone(),
        hunks: file.hunks.clone(),
        semantic,
        stamp,
    });
}

pub fn run_enrich(highlighter: &Highlighter, job: EnrichJob) -> EnrichOutcome {
    let mut file = FileDiff {
        path: job.path,
        old_path: None,
        status: diffler_core::model::FileStatus::Modified,
        binary: false,
        old_text: job.old_text,
        new_text: job.new_text,
        hunks: job.hunks,
        hashes: HashCache::default(),
        blobs: BlobIds::default(),
    };
    // a rule or theme change enriches hunks an earlier grammar already
    // marked, so we clear those marks before this one sets its own
    for line in file.hunks.iter_mut().flat_map(|hunk| &mut hunk.lines) {
        line.emphasis.clear();
        line.reformat_only = false;
    }
    let structural = job.stamp.algorithm == DiffAlgorithm::Structural;
    if !(job.semantic && highlighter.syntactic_emphasis(&mut file, structural)) {
        pairing::enrich_file(&mut file);
    }
    let highlight = |text: &Option<String>| {
        text.as_deref()
            .map(|content| highlighter.highlight(&file.path, content))
            .unwrap_or_default()
    };
    let highlights = FileHighlights {
        hash: job.hash.clone(),
        old: highlight(&file.old_text),
        new: highlight(&file.new_text),
    };
    let scope = FileScope {
        hash: job.hash.clone(),
        index: file
            .new_text
            .as_deref()
            .map(|content| highlighter.scope_index(&file.path, content))
            .unwrap_or_default(),
    };
    EnrichOutcome {
        path: file.path,
        hash: job.hash,
        hunks: file.hunks,
        highlights,
        scope,
        stamp: job.stamp,
    }
}

impl App {
    /// Queue enrichment for the selected diff file and its neighbours, so a
    /// j/k step usually reaches a ready file.
    pub(crate) fn queue_enrich_selected(&mut self) {
        let Some(diff) = self.diff.as_ref() else {
            return;
        };
        let selected = diff.selected;
        let file_count = diff
            .commit_model
            .as_ref()
            .unwrap_or_else(|| self.review.model())
            .files
            .len();
        let mut targets = vec![selected];
        targets.extend(selected.checked_sub(1));
        if selected + 1 < file_count {
            targets.push(selected + 1);
        }
        targets.extend(self.referenced_files());
        for index in targets {
            self.queue_enrich_file(index);
        }
        self.queue_enrich_context_files();
    }

    fn referenced_files(&self) -> Vec<usize> {
        let Some(diff) = self.diff.as_ref().filter(|diff| diff.refs_visible()) else {
            return Vec::new();
        };
        let Some(lens) = diff.lens.as_ref() else {
            return Vec::new();
        };
        let model = diff.model(&self.review);
        lens.view
            .refs
            .iter()
            .filter(|entry| entry.group_len.is_some())
            .filter_map(|entry| model.files.iter().position(|file| file.path == entry.path))
            .collect()
    }

    fn queue_enrich_context_files(&mut self) {
        let stamp = self.enrich_stamp();
        let Some(diff) = self.diff.as_ref() else {
            return;
        };
        for file in &diff.context_files {
            let ready = diff
                .highlights
                .get(&file.path)
                .is_some_and(|cached| cached.hash == file.sides_hash());
            queue_if_stale(
                &mut self.enrich_inflight,
                &mut self.pending_enrich,
                file,
                false,
                stamp,
                ready,
            );
        }
    }

    fn queue_enrich_file(&mut self, index: usize) {
        let semantic = self.config.ui.semantic_diff;
        let stamp = self.enrich_stamp();
        let Some(diff) = self.diff.as_ref() else {
            return;
        };
        let model = diff
            .commit_model
            .as_ref()
            .unwrap_or_else(|| self.review.model());
        let Some(file) = model.files.get(index) else {
            return;
        };
        if file.binary || file.hunks.is_empty() {
            return;
        }
        let ready = diff
            .highlights
            .get(&file.path)
            .is_some_and(|cached| cached.hash == file.sides_hash())
            && diff.is_enriched(&file.path);
        queue_if_stale(
            &mut self.enrich_inflight,
            &mut self.pending_enrich,
            file,
            semantic,
            stamp,
            ready,
        );
    }

    /// Install a finished enrichment wherever the file still has the same
    /// content. A stale outcome installs nothing, and the next frame queues
    /// the new content.
    pub(crate) fn on_enriched(&mut self, outcome: EnrichOutcome) {
        // a highlighter rebuild clears every marker, and the hash may since
        // mark a fresh job for the same content, so we leave that one alone
        if outcome.stamp.highlighter == self.highlighter_generation {
            self.enrich_inflight.remove(&outcome.hash);
        }
        if outcome.stamp != self.enrich_stamp() {
            return;
        }
        self.install_status_enrichment(&outcome);
        let algorithm = self.config.diff.algorithm;
        let indent_heuristic = self.config.diff.indent_heuristic;
        let Some(diff) = self.diff.as_mut() else {
            return;
        };
        let context = diff.context.get(&outcome.path).copied();
        // reinstalling an expansion reshapes the hunks under the cursor, so we
        // name its line first, while the rows still match the old hunks
        let positions = (context.is_some() && diff.rows_show(&outcome.path))
            .then(|| diff.capture_positions(&self.review));
        let same = |file: &FileDiff| file.path == outcome.path && file.sides_hash() == outcome.hash;
        let file = match diff.commit_model.as_mut() {
            Some(model) => model.files.iter_mut().find(|f| same(f)),
            None => self.review.model_mut().files.iter_mut().find(|f| same(f)),
        }
        .or_else(|| diff.context_files.iter_mut().find(|f| same(f)));
        let Some(file) = file else {
            return;
        };
        file.hunks = outcome.hunks;
        // enrichment returns default-context hunks, so we reapply the expansion
        let reshaped = context.is_some_and(|context| {
            super::expand::apply_context(file, context, algorithm, indent_heuristic)
        });
        // the walkthrough render cache holds its own snapshot of this file, so
        // we copy the new hunks into it
        let hunks = file.hunks.clone();
        diff.highlights
            .insert(outcome.path.clone(), outcome.highlights);
        diff.scopes.insert(outcome.path.clone(), outcome.scope);
        diff.mark_enriched(&outcome.path);
        if let Some(cached) = diff
            .merged_model
            .as_mut()
            .and_then(|merged| merged.files.iter_mut().find(|f| same(f)))
        {
            cached.hunks = hunks;
        }
        // fold detection and labels read the new scope index, so we rebuild
        // the file on screen
        match positions.filter(|_| reshaped) {
            Some(positions) => diff.rebuild_in_place(&self.review, positions),
            None if reshaped || diff.rows_show(&outcome.path) => diff.mark_rows_dirty(),
            None => {}
        }
    }
}

impl App {
    /// Test pump: run queued enrichment inline, as the main loop's workers
    /// would, so snapshots capture the enriched frame.
    pub fn enrich_now(&mut self) {
        let jobs = std::mem::take(&mut self.pending_enrich);
        for job in jobs {
            let outcome = run_enrich(&self.highlighter, job);
            self.on_enriched(outcome);
        }
    }
}

#[cfg(test)]
mod tests {
    use diffler_core::model::{DiffLine, HunkId, LineKind};

    use super::*;

    #[test]
    fn enriching_again_drops_the_marks_an_earlier_grammar_set() {
        let mut line = DiffLine::new(LineKind::Added, None, Some(1), "  a: 1".to_owned());
        line.emphasis.push(0..2);
        line.reformat_only = true;
        let job = EnrichJob {
            path: "a.yml".to_owned(),
            hash: "h".to_owned(),
            old_text: None,
            new_text: Some("  a: 1\n".to_owned()),
            hunks: vec![Hunk {
                id: HunkId("h".to_owned()),
                old_start: 0,
                old_lines: 0,
                new_start: 1,
                new_lines: 1,
                context: String::new(),
                lines: vec![line],
            }],
            semantic: true,
            stamp: EnrichStamp {
                algorithm: DiffAlgorithm::Structural,
                highlighter: 0,
            },
        };
        let outcome = run_enrich(&Highlighter::default(), job);
        let line = &outcome.hunks[0].lines[0];
        assert!(!line.reformat_only);
        assert!(line.emphasis.is_empty());
    }
}