Skip to main content

xdiff_live/
utils.rs

1use anyhow::Result;
2use console::{style, Style};
3use similar::{ChangeTag, TextDiff};
4use std::fmt;
5use std::fmt::Write as _;
6use std::io::Write as _;
7use syntect::easy::HighlightLines;
8use syntect::highlighting::ThemeSet;
9use syntect::parsing::SyntaxSet;
10use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
11
12/// A wrapper struct for displaying line numbers in diff output.
13/// 
14/// This struct handles the formatting of line numbers, including proper
15/// alignment and handling of missing line numbers (represented as `None`).
16struct Line(Option<usize>);
17
18impl fmt::Display for Line {
19    /// Formats the line number for display in diff output.
20    /// 
21    /// # Arguments
22    /// 
23    /// * `f` - The formatter to write to
24    /// 
25    /// # Returns
26    /// 
27    /// A `fmt::Result` indicating success or failure of the formatting operation.
28    /// 
29    /// # Behavior
30    /// 
31    /// - If the line number is `None`, displays four spaces
32    /// - If the line number is `Some(idx)`, displays the line number (idx + 1) left-aligned in a 4-character field
33    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
34        match self.0 {
35            None => write!(f, "    "),
36            Some(idx) => write!(f, "{:<4}", idx + 1),
37        }
38    }
39}
40
41/// Generates a colored, side-by-side diff of two text strings.
42///
43/// This function compares two text strings line by line and produces a formatted
44/// diff output with color coding and line numbers. The output uses:
45/// - Red color for deleted lines (-)
46/// - Green color for added lines (+)
47/// - Dim style for unchanged lines
48/// - Underlined text for emphasized changes within lines
49///
50/// # Arguments
51///
52/// * `text1` - The original text (left side of the diff)
53/// * `text2` - The modified text (right side of the diff)
54///
55/// # Returns
56///
57/// A `Result<String>` containing the formatted diff output, or an error if
58/// the diff generation fails.
59///
60/// # Examples
61///
62/// ```
63/// use xdiff_live::diff_text;
64///
65/// let text1 = "hello\nworld";
66/// let text2 = "hello\nrust";
67/// let diff = diff_text(text1, text2).unwrap();
68/// println!("{}", diff);
69/// ```
70///
71/// # Output Format
72///
73/// The output format shows:
74/// - Line numbers for both old and new text
75/// - A separator character (|)
76/// - The diff marker (-, +, or space)
77/// - The actual line content with highlighting
78///
79/// Groups of changes are separated by horizontal lines for better readability.
80pub fn diff_text(text1: &str, text2: &str) -> Result<String> {
81    let mut output = String::new();
82
83    let diff = TextDiff::from_lines(text1, text2);
84
85    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
86        if idx > 0 {
87            // println!("{:-^1$}", "-", 80);
88            output.push_str(&format!("{:-^1$}", "-", 80));
89        }
90        for op in group {
91            for change in diff.iter_inline_changes(op) {
92                let (sign, s) = match change.tag() {
93                    ChangeTag::Delete => ("-", Style::new().red()),
94                    ChangeTag::Insert => ("+", Style::new().green()),
95                    ChangeTag::Equal => (" ", Style::new().dim()),
96                };
97                // print!(
98                //     "{}{} |{}",
99                //     style(Line(change.old_index())).dim(),
100                //     style(Line(change.new_index())).dim(),
101                //     s.apply_to(sign).bold(),
102                // );
103                output.push_str(&format!(
104                    "{} {} | {}",
105                    style(Line(change.old_index())).dim(),
106                    style(Line(change.new_index())).dim(),
107                    s.apply_to(sign).bold(),
108                ));
109                for (emphasized, value) in change.iter_strings_lossy() {
110                    if emphasized {
111                        // print!("{}", s.apply_to(value).underlined().on_black());
112                        output.push_str(&format!("{}", s.apply_to(value).underlined().on_black()));
113                        // write!(&mut output, "{}", s.apply_to(value).underlined().on_black())?;
114                    } else {
115                        // print!("{}", s.apply_to(value));
116                        output.push_str(&format!("{}", s.apply_to(value)));
117                        // write!(&mut output, "{}", s.apply_to(value))?;
118                    }
119                }
120                if change.missing_newline() {
121                    // println!();
122                    output.push_str("\n");
123                }
124            }
125        }
126    }
127    Ok(output)
128}
129
130/// Applies syntax highlighting to text based on the file extension and theme.
131///
132/// This function uses the `syntect` library to apply syntax highlighting to the
133/// provided text. It automatically detects the syntax based on the file extension
134/// and applies the specified theme for colorization.
135///
136/// # Arguments
137///
138/// * `text` - The text content to highlight
139/// * `extension` - The file extension (e.g., "rs", "json", "yaml") used for syntax detection
140/// * `theme` - Optional theme name. If `None`, defaults to "Solarized (dark)"
141///
142/// # Returns
143///
144/// A `Result<String>` containing the highlighted text with ANSI escape codes,
145/// or an error if highlighting fails.
146///
147/// # Examples
148///
149/// ```
150/// use xdiff_live::highlight_text;
151///
152/// let json_text = r#"{"name": "example", "value": 42}"#;
153/// let highlighted = highlight_text(json_text, "json", None).unwrap();
154/// println!("{}", highlighted);
155/// ```
156///
157/// # Supported Themes
158///
159/// Common themes include:
160/// - "Solarized (dark)" (default)
161/// - "Solarized (light)"
162/// - "InspiredGitHub"
163/// - "Monokai"
164/// - "base16-ocean.dark"
165///
166/// # Note
167///
168/// The function loads syntax and theme sets using defaults. For better performance
169/// in applications that call this function frequently, consider caching these sets.
170pub fn highlight_text(text: &str, extension: &str, theme: Option<&str>) -> Result<String> {
171    // Load these once at the start of your program
172    let ps = SyntaxSet::load_defaults_newlines();
173    let ts = ThemeSet::load_defaults();
174
175    let syntax = if let Some(s) = ps.find_syntax_by_extension(extension) {
176        s
177    } else {
178        ps.find_syntax_plain_text()
179    };
180    let mut h = HighlightLines::new(
181        syntax,
182        &ts.themes[theme.unwrap_or_else(|| "Solarized (dark)")],
183    );
184
185    let mut output = String::new();
186
187    for line in LinesWithEndings::from(text) {
188        let ranges = h.highlight_line(line, &ps).unwrap();
189        let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
190        write!(&mut output, "{}", escaped)?;
191    }
192
193    Ok(output)
194}
195
196/// Processes and formats error output for display to the user.
197///
198/// This function takes a `Result` and handles error display appropriately based on
199/// whether the output is directed to a TTY (terminal) or not. When outputting to
200/// a terminal, errors are displayed in red color for better visibility.
201///
202/// # Arguments
203///
204/// * `result` - A `Result<()>` to process. If it's an `Err`, the error will be
205///   formatted and written to stderr.
206///
207/// # Returns
208///
209/// Always returns `Ok(())` regardless of the input result. The actual error
210/// handling is done by writing to stderr.
211///
212/// # Behavior
213///
214/// - **Success case**: Does nothing and returns `Ok(())`
215/// - **Error case with TTY**: Writes the error to stderr in red color
216/// - **Error case without TTY**: Writes the error to stderr in plain text
217///
218/// # Examples
219///
220/// ```
221/// use xdiff_live::process_error_output;
222/// use anyhow::Result;
223///
224/// fn might_fail() -> Result<()> {
225///     Err(anyhow::anyhow!("Something went wrong"))
226/// }
227///
228/// let result = might_fail();
229/// process_error_output(result).unwrap();
230/// ```
231///
232/// # Note
233///
234/// This function uses the `atty` crate to detect if stderr is connected to a TTY,
235/// allowing it to provide colored output when appropriate while maintaining
236/// compatibility with non-interactive environments.
237pub fn process_error_output(result: Result<()>) -> Result<()> {
238    match result {
239        Ok(_) => {}
240        Err(e) => {
241            let stderr = std::io::stderr();
242            let mut stderr = stderr.lock();
243            if atty::is(atty::Stream::Stderr) {
244                let s = Style::new().red();
245                writeln!(stderr, "{}", s.apply_to(format!("{:?}", e)))?;
246            } else {
247                writeln!(stderr, "{:?}", e)?;
248            }
249        }
250    }
251
252    Ok(())
253}
254
255#[cfg(test)]
256mod tests {
257    use serde_json::json;
258
259    use super::*;
260
261    /// Tests the `diff_text` function with a simple two-line difference.
262    ///
263    /// This test verifies that the diff function correctly identifies and formats
264    /// the difference between two similar text strings, comparing the output
265    /// against a known expected result stored in a fixture file.
266    #[test]
267    fn diff_text_should_work() {
268        let text1 = "foo\nbar";
269        let text2 = "foo\nbaz";
270
271        // let expected = "1    1    |  foo\n2         | -bar\n     2    | +baz\n";
272        let expected = include_str!("../fixtures/diff1.txt");
273
274        assert_eq!(diff_text(text1, text2).unwrap(), expected);
275    }
276
277    /// Tests the `highlight_text` function with JSON syntax highlighting.
278    ///
279    /// This test verifies that the syntax highlighting function correctly applies
280    /// JSON syntax highlighting to a structured JSON object, comparing the output
281    /// against a known expected result stored in a fixture file.
282    #[test]
283    fn highlight_text_should_work() {
284        let v = json!({
285            "foo": "bar",
286            "baz": "qux",
287        });
288        let text = serde_json::to_string_pretty(&v).unwrap();
289        let expected = include_str!("../fixtures/highlight1.txt");
290
291        assert_eq!(highlight_text(&text, "json", None).unwrap(), expected);
292    }
293}