xdiff-live 0.1.1

A live diff tool for comparing files and directories.
Documentation
use anyhow::Result;
use console::{style, Style};
use similar::{ChangeTag, TextDiff};
use std::fmt;
use std::fmt::Write as _;
use std::io::Write as _;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};

/// A wrapper struct for displaying line numbers in diff output.
/// 
/// This struct handles the formatting of line numbers, including proper
/// alignment and handling of missing line numbers (represented as `None`).
struct Line(Option<usize>);

impl fmt::Display for Line {
    /// Formats the line number for display in diff output.
    /// 
    /// # Arguments
    /// 
    /// * `f` - The formatter to write to
    /// 
    /// # Returns
    /// 
    /// A `fmt::Result` indicating success or failure of the formatting operation.
    /// 
    /// # Behavior
    /// 
    /// - If the line number is `None`, displays four spaces
    /// - If the line number is `Some(idx)`, displays the line number (idx + 1) left-aligned in a 4-character field
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self.0 {
            None => write!(f, "    "),
            Some(idx) => write!(f, "{:<4}", idx + 1),
        }
    }
}

/// Generates a colored, side-by-side diff of two text strings.
///
/// This function compares two text strings line by line and produces a formatted
/// diff output with color coding and line numbers. The output uses:
/// - Red color for deleted lines (-)
/// - Green color for added lines (+)
/// - Dim style for unchanged lines
/// - Underlined text for emphasized changes within lines
///
/// # Arguments
///
/// * `text1` - The original text (left side of the diff)
/// * `text2` - The modified text (right side of the diff)
///
/// # Returns
///
/// A `Result<String>` containing the formatted diff output, or an error if
/// the diff generation fails.
///
/// # Examples
///
/// ```
/// use xdiff_live::diff_text;
///
/// let text1 = "hello\nworld";
/// let text2 = "hello\nrust";
/// let diff = diff_text(text1, text2).unwrap();
/// println!("{}", diff);
/// ```
///
/// # Output Format
///
/// The output format shows:
/// - Line numbers for both old and new text
/// - A separator character (|)
/// - The diff marker (-, +, or space)
/// - The actual line content with highlighting
///
/// Groups of changes are separated by horizontal lines for better readability.
pub fn diff_text(text1: &str, text2: &str) -> Result<String> {
    let mut output = String::new();

    let diff = TextDiff::from_lines(text1, text2);

    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
        if idx > 0 {
            // println!("{:-^1$}", "-", 80);
            output.push_str(&format!("{:-^1$}", "-", 80));
        }
        for op in group {
            for change in diff.iter_inline_changes(op) {
                let (sign, s) = match change.tag() {
                    ChangeTag::Delete => ("-", Style::new().red()),
                    ChangeTag::Insert => ("+", Style::new().green()),
                    ChangeTag::Equal => (" ", Style::new().dim()),
                };
                // print!(
                //     "{}{} |{}",
                //     style(Line(change.old_index())).dim(),
                //     style(Line(change.new_index())).dim(),
                //     s.apply_to(sign).bold(),
                // );
                output.push_str(&format!(
                    "{} {} | {}",
                    style(Line(change.old_index())).dim(),
                    style(Line(change.new_index())).dim(),
                    s.apply_to(sign).bold(),
                ));
                for (emphasized, value) in change.iter_strings_lossy() {
                    if emphasized {
                        // print!("{}", s.apply_to(value).underlined().on_black());
                        output.push_str(&format!("{}", s.apply_to(value).underlined().on_black()));
                        // write!(&mut output, "{}", s.apply_to(value).underlined().on_black())?;
                    } else {
                        // print!("{}", s.apply_to(value));
                        output.push_str(&format!("{}", s.apply_to(value)));
                        // write!(&mut output, "{}", s.apply_to(value))?;
                    }
                }
                if change.missing_newline() {
                    // println!();
                    output.push_str("\n");
                }
            }
        }
    }
    Ok(output)
}

/// Applies syntax highlighting to text based on the file extension and theme.
///
/// This function uses the `syntect` library to apply syntax highlighting to the
/// provided text. It automatically detects the syntax based on the file extension
/// and applies the specified theme for colorization.
///
/// # Arguments
///
/// * `text` - The text content to highlight
/// * `extension` - The file extension (e.g., "rs", "json", "yaml") used for syntax detection
/// * `theme` - Optional theme name. If `None`, defaults to "Solarized (dark)"
///
/// # Returns
///
/// A `Result<String>` containing the highlighted text with ANSI escape codes,
/// or an error if highlighting fails.
///
/// # Examples
///
/// ```
/// use xdiff_live::highlight_text;
///
/// let json_text = r#"{"name": "example", "value": 42}"#;
/// let highlighted = highlight_text(json_text, "json", None).unwrap();
/// println!("{}", highlighted);
/// ```
///
/// # Supported Themes
///
/// Common themes include:
/// - "Solarized (dark)" (default)
/// - "Solarized (light)"
/// - "InspiredGitHub"
/// - "Monokai"
/// - "base16-ocean.dark"
///
/// # Note
///
/// The function loads syntax and theme sets using defaults. For better performance
/// in applications that call this function frequently, consider caching these sets.
pub fn highlight_text(text: &str, extension: &str, theme: Option<&str>) -> Result<String> {
    // Load these once at the start of your program
    let ps = SyntaxSet::load_defaults_newlines();
    let ts = ThemeSet::load_defaults();

    let syntax = if let Some(s) = ps.find_syntax_by_extension(extension) {
        s
    } else {
        ps.find_syntax_plain_text()
    };
    let mut h = HighlightLines::new(
        syntax,
        &ts.themes[theme.unwrap_or_else(|| "Solarized (dark)")],
    );

    let mut output = String::new();

    for line in LinesWithEndings::from(text) {
        let ranges = h.highlight_line(line, &ps).unwrap();
        let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
        write!(&mut output, "{}", escaped)?;
    }

    Ok(output)
}

/// Processes and formats error output for display to the user.
///
/// This function takes a `Result` and handles error display appropriately based on
/// whether the output is directed to a TTY (terminal) or not. When outputting to
/// a terminal, errors are displayed in red color for better visibility.
///
/// # Arguments
///
/// * `result` - A `Result<()>` to process. If it's an `Err`, the error will be
///   formatted and written to stderr.
///
/// # Returns
///
/// Always returns `Ok(())` regardless of the input result. The actual error
/// handling is done by writing to stderr.
///
/// # Behavior
///
/// - **Success case**: Does nothing and returns `Ok(())`
/// - **Error case with TTY**: Writes the error to stderr in red color
/// - **Error case without TTY**: Writes the error to stderr in plain text
///
/// # Examples
///
/// ```
/// use xdiff_live::process_error_output;
/// use anyhow::Result;
///
/// fn might_fail() -> Result<()> {
///     Err(anyhow::anyhow!("Something went wrong"))
/// }
///
/// let result = might_fail();
/// process_error_output(result).unwrap();
/// ```
///
/// # Note
///
/// This function uses the `atty` crate to detect if stderr is connected to a TTY,
/// allowing it to provide colored output when appropriate while maintaining
/// compatibility with non-interactive environments.
pub fn process_error_output(result: Result<()>) -> Result<()> {
    match result {
        Ok(_) => {}
        Err(e) => {
            let stderr = std::io::stderr();
            let mut stderr = stderr.lock();
            if atty::is(atty::Stream::Stderr) {
                let s = Style::new().red();
                writeln!(stderr, "{}", s.apply_to(format!("{:?}", e)))?;
            } else {
                writeln!(stderr, "{:?}", e)?;
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    /// Tests the `diff_text` function with a simple two-line difference.
    ///
    /// This test verifies that the diff function correctly identifies and formats
    /// the difference between two similar text strings, comparing the output
    /// against a known expected result stored in a fixture file.
    #[test]
    fn diff_text_should_work() {
        let text1 = "foo\nbar";
        let text2 = "foo\nbaz";

        // let expected = "1    1    |  foo\n2         | -bar\n     2    | +baz\n";
        let expected = include_str!("../fixtures/diff1.txt");

        assert_eq!(diff_text(text1, text2).unwrap(), expected);
    }

    /// Tests the `highlight_text` function with JSON syntax highlighting.
    ///
    /// This test verifies that the syntax highlighting function correctly applies
    /// JSON syntax highlighting to a structured JSON object, comparing the output
    /// against a known expected result stored in a fixture file.
    #[test]
    fn highlight_text_should_work() {
        let v = json!({
            "foo": "bar",
            "baz": "qux",
        });
        let text = serde_json::to_string_pretty(&v).unwrap();
        let expected = include_str!("../fixtures/highlight1.txt");

        assert_eq!(highlight_text(&text, "json", None).unwrap(), expected);
    }
}