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}