Skip to main content

lex_core/lex/testing/
text_diff.rs

1//! Line-based text diffing utilities for testing
2//!
3//! Provides utilities for comparing text strings line-by-line with clear
4//! reporting of differences. This is especially useful for testing formatters
5//! and serializers where exact text output matters.
6
7/// Assert that two strings are equal, with line-by-line diff on failure
8///
9/// This function compares two strings line by line and provides detailed
10/// error messages showing exactly which lines differ.
11///
12/// # Panics
13///
14/// Panics if the strings are not equal, with a detailed diff showing:
15/// - Line numbers where differences occur
16/// - Expected vs actual content for each differing line
17/// - Lines that were added or removed
18///
19/// # Example
20///
21/// ```
22/// use lex_parser::lex::testing::text_diff::assert_text_eq;
23///
24/// let expected = "line 1\nline 2\nline 3";
25/// let actual = "line 1\nline 2\nline 3";
26/// assert_text_eq(expected, actual); // passes
27/// ```
28pub fn assert_text_eq(expected: &str, actual: &str) {
29    if expected == actual {
30        return;
31    }
32
33    let expected_lines: Vec<&str> = expected.lines().collect();
34    let actual_lines: Vec<&str> = actual.lines().collect();
35
36    let mut diff_lines = Vec::new();
37    let max_lines = expected_lines.len().max(actual_lines.len());
38
39    for i in 0..max_lines {
40        let expected_line = expected_lines.get(i);
41        let actual_line = actual_lines.get(i);
42
43        match (expected_line, actual_line) {
44            (Some(exp), Some(act)) if exp == act => {
45                // Lines match, no diff
46            }
47            (Some(exp), Some(act)) => {
48                diff_lines.push(format!("Line {}: MISMATCH", i + 1));
49                diff_lines.push(format!("  Expected: {exp:?}"));
50                diff_lines.push(format!("  Actual:   {act:?}"));
51            }
52            (Some(exp), None) => {
53                diff_lines.push(format!("Line {}: MISSING in actual", i + 1));
54                diff_lines.push(format!("  Expected: {exp:?}"));
55            }
56            (None, Some(act)) => {
57                diff_lines.push(format!("Line {}: EXTRA in actual", i + 1));
58                diff_lines.push(format!("  Actual:   {act:?}"));
59            }
60            (None, None) => unreachable!(),
61        }
62    }
63
64    if !diff_lines.is_empty() {
65        panic!(
66            "\n\nText comparison failed:\n{}\n\nExpected ({} lines):\n{}\n\nActual ({} lines):\n{}\n",
67            diff_lines.join("\n"),
68            expected_lines.len(),
69            expected,
70            actual_lines.len(),
71            actual
72        );
73    }
74}
75
76/// Compare two strings and return a diff report without panicking
77///
78/// Returns `None` if the strings are equal, or `Some(diff_report)` if they differ.
79pub fn diff_text(expected: &str, actual: &str) -> Option<String> {
80    if expected == actual {
81        return None;
82    }
83
84    let expected_lines: Vec<&str> = expected.lines().collect();
85    let actual_lines: Vec<&str> = actual.lines().collect();
86
87    let mut diff_lines = Vec::new();
88    let max_lines = expected_lines.len().max(actual_lines.len());
89
90    for i in 0..max_lines {
91        let expected_line = expected_lines.get(i);
92        let actual_line = actual_lines.get(i);
93
94        match (expected_line, actual_line) {
95            (Some(exp), Some(act)) if exp == act => {
96                // Lines match
97            }
98            (Some(exp), Some(act)) => {
99                diff_lines.push(format!("Line {}: MISMATCH", i + 1));
100                diff_lines.push(format!("  Expected: {exp:?}"));
101                diff_lines.push(format!("  Actual:   {act:?}"));
102            }
103            (Some(exp), None) => {
104                diff_lines.push(format!("Line {}: MISSING in actual", i + 1));
105                diff_lines.push(format!("  Expected: {exp:?}"));
106            }
107            (None, Some(act)) => {
108                diff_lines.push(format!("Line {}: EXTRA in actual", i + 1));
109                diff_lines.push(format!("  Actual:   {act:?}"));
110            }
111            (None, None) => unreachable!(),
112        }
113    }
114
115    if diff_lines.is_empty() {
116        None
117    } else {
118        Some(format!(
119            "Text differs:\n{}\n\nExpected ({} lines):\n{}\n\nActual ({} lines):\n{}",
120            diff_lines.join("\n"),
121            expected_lines.len(),
122            expected,
123            actual_lines.len(),
124            actual
125        ))
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_assert_text_eq_identical() {
135        assert_text_eq("hello\nworld", "hello\nworld");
136    }
137
138    #[test]
139    fn test_assert_text_eq_empty() {
140        assert_text_eq("", "");
141    }
142
143    #[test]
144    #[should_panic(expected = "Text comparison failed")]
145    fn test_assert_text_eq_different() {
146        assert_text_eq("hello\nworld", "hello\nplanet");
147    }
148
149    #[test]
150    #[should_panic(expected = "MISSING in actual")]
151    fn test_assert_text_eq_missing_line() {
152        assert_text_eq("hello\nworld\nfoo", "hello\nworld");
153    }
154
155    #[test]
156    #[should_panic(expected = "EXTRA in actual")]
157    fn test_assert_text_eq_extra_line() {
158        assert_text_eq("hello\nworld", "hello\nworld\nfoo");
159    }
160
161    #[test]
162    fn test_diff_text_identical() {
163        assert_eq!(diff_text("hello\nworld", "hello\nworld"), None);
164    }
165
166    #[test]
167    fn test_diff_text_different() {
168        let result = diff_text("hello\nworld", "hello\nplanet");
169        assert!(result.is_some());
170        let diff = result.unwrap();
171        assert!(diff.contains("Line 2: MISMATCH"));
172        assert!(diff.contains("world"));
173        assert!(diff.contains("planet"));
174    }
175}