travelagent-core 1.10.3

Core library for travelagent code review tool
Documentation
//! Post-fetch syntax decoration for parsed diffs.
//!
//! VCS backends return undecorated `DiffFile`s (no `highlighted_spans`).
//! Callers that want syntax highlighting run this pass over the slice
//! before rendering. Keeping decoration out of the backend trait means:
//!   - `travelagent-core` exposes a pure diff shape from VCS methods,
//!   - future backends don't need to know about syntect/themes,
//!   - diff-time options (word-diff, max hunk, etc.) can grow on the
//!     trait without touching every implementation.
//!
//! The highlighting algorithm is lifted verbatim from the pre-H7-followup
//! backend code and matches the behaviour tested in `vcs::git::diff` and
//! `vcs::diff_parser`: per-file, both the old and new sides are highlighted
//! independently (so parser state stays valid across deletions/additions),
//! then each `DiffLine` is reassembled with a diff-background tint.

use crate::model::{DiffFile, DiffLine};
use crate::syntax::SyntaxHighlighter;

/// Populate `highlighted_spans` on every line of every file in `files`.
///
/// Files with neither `new_path` nor `old_path` are left alone — there's no
/// resolvable syntax and the backend already set `highlighted_spans = None`.
/// Lines whose origin/side has no resolvable syntax stay `None` too; this
/// matches the pre-refactor behaviour where `highlight_file_lines` returning
/// `None` short-circuits the per-line lookup.
pub fn decorate_diff_files(files: &mut [DiffFile], highlighter: &SyntaxHighlighter) {
    for file in files {
        if file.is_binary || file.is_too_large || file.hunks.is_empty() {
            continue;
        }

        // Use new_path when present (the current version of the file); fall
        // back to old_path for pure deletions. Commit-message synthetic files
        // have neither.
        let file_path = match file.new_path.as_ref().or(file.old_path.as_ref()) {
            Some(p) => p.clone(),
            None => continue,
        };

        for hunk in &mut file.hunks {
            decorate_hunk_lines(&mut hunk.lines, &file_path, highlighter);
        }
    }
}

fn decorate_hunk_lines(
    lines: &mut [DiffLine],
    file_path: &std::path::Path,
    highlighter: &SyntaxHighlighter,
) {
    let line_contents: Vec<String> = lines.iter().map(|l| l.content.clone()).collect();
    let line_origins: Vec<_> = lines.iter().map(|l| l.origin).collect();

    let highlight_sequences =
        SyntaxHighlighter::split_diff_lines_for_highlighting(&line_contents, &line_origins);
    let old_highlighted_lines =
        highlighter.highlight_file_lines(file_path, &highlight_sequences.old_lines);
    let new_highlighted_lines =
        highlighter.highlight_file_lines(file_path, &highlight_sequences.new_lines);

    for (idx, line) in lines.iter_mut().enumerate() {
        line.highlighted_spans = highlighter.highlighted_line_for_diff_with_background(
            old_highlighted_lines.as_deref(),
            new_highlighted_lines.as_deref(),
            highlight_sequences.old_line_indices[idx],
            highlight_sequences.new_line_indices[idx],
            line.origin,
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{DiffHunk, FileStatus, LineOrigin};
    use std::path::PathBuf;

    fn rust_file_with_one_hunk() -> DiffFile {
        DiffFile {
            old_path: None,
            new_path: Some(PathBuf::from("main.rs")),
            status: FileStatus::Added,
            hunks: vec![DiffHunk {
                header: "@@ -0,0 +1,3 @@".to_string(),
                lines: vec![
                    DiffLine {
                        origin: LineOrigin::Addition,
                        content: "fn main() {".to_string(),
                        old_lineno: None,
                        new_lineno: Some(1),
                        highlighted_spans: None,
                    },
                    DiffLine {
                        origin: LineOrigin::Addition,
                        content: "    let x = 42;".to_string(),
                        old_lineno: None,
                        new_lineno: Some(2),
                        highlighted_spans: None,
                    },
                    DiffLine {
                        origin: LineOrigin::Addition,
                        content: "}".to_string(),
                        old_lineno: None,
                        new_lineno: Some(3),
                        highlighted_spans: None,
                    },
                ],
                old_start: 0,
                old_count: 0,
                new_start: 1,
                new_count: 3,
            }],
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        }
    }

    #[test]
    fn pre_decoration_lines_have_no_highlighted_spans() {
        let file = rust_file_with_one_hunk();
        for line in &file.hunks[0].lines {
            assert!(line.highlighted_spans.is_none());
        }
    }

    #[test]
    fn decorate_populates_spans_for_recognized_file_types() {
        let mut files = vec![rust_file_with_one_hunk()];
        let highlighter = SyntaxHighlighter::default();

        decorate_diff_files(&mut files, &highlighter);

        for (idx, line) in files[0].hunks[0].lines.iter().enumerate() {
            assert!(
                line.highlighted_spans.is_some(),
                "line {idx} should have spans after decoration"
            );
            let spans = line.highlighted_spans.as_ref().unwrap();
            assert!(!spans.is_empty(), "line {idx} should have non-empty spans");
        }
    }

    #[test]
    fn decorate_skips_binary_files() {
        let mut files = vec![DiffFile {
            old_path: None,
            new_path: Some(PathBuf::from("image.png")),
            status: FileStatus::Added,
            hunks: Vec::new(),
            is_binary: true,
            is_too_large: false,
            is_commit_message: false,
        }];
        let highlighter = SyntaxHighlighter::default();

        decorate_diff_files(&mut files, &highlighter);

        // No panic, no hunks, nothing to decorate — just verify we didn't trip.
        assert!(files[0].hunks.is_empty());
    }

    #[test]
    fn decorate_leaves_spans_none_for_unresolvable_syntax() {
        // A file with no path and no extension-less heuristic hit: the backend
        // would already have left spans as None, and decorate must preserve
        // that rather than inventing content.
        let mut files = vec![DiffFile {
            old_path: None,
            new_path: None,
            status: FileStatus::Modified,
            hunks: vec![DiffHunk {
                header: "@@ -1 +1 @@".to_string(),
                lines: vec![DiffLine {
                    origin: LineOrigin::Context,
                    content: "no path".to_string(),
                    old_lineno: Some(1),
                    new_lineno: Some(1),
                    highlighted_spans: None,
                }],
                old_start: 1,
                old_count: 1,
                new_start: 1,
                new_count: 1,
            }],
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        }];
        let highlighter = SyntaxHighlighter::default();

        decorate_diff_files(&mut files, &highlighter);

        assert!(files[0].hunks[0].lines[0].highlighted_spans.is_none());
    }
}