diff --git a/src/diff.rs b/src/diff.rs index b05988f..971dfba 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,5 +1,7 @@ use similar::{ChangeTag, TextDiff}; +use crate::text::{Alignment, pad_to_width, truncate_to_width}; + pub struct DiffArgs { pub file_a: String, pub file_b: String, @@ -63,9 +65,13 @@ fn print_side_by_side<'a>(diff: &TextDiff<'a, 'a, 'a, str>, name_a: &str, name_b // Header println!( - "\x1b[1m{:(diff: &TextDiff<'a, 'a, 'a, str>, name_a: &str, name_b match change.tag() { ChangeTag::Equal => { - let left = truncate(line, col_width); - let right = truncate(line, col_width); + let left = truncate_to_width(line, col_width); println!( - "\x1b[90m{: { - let left = truncate(line, col_width); - println!("\x1b[31m{: { println!( - "{: String { - if s.len() <= max { - s.to_string() - } else if max > 3 { - format!("{}...", &s[..max - 3]) - } else { - s[..max].to_string() - } -} diff --git a/src/text.rs b/src/text.rs index dbb5887..5aa9198 100644 --- a/src/text.rs +++ b/src/text.rs @@ -18,18 +18,18 @@ pub fn repeat_char(ch: char, count: usize) -> String { std::iter::repeat_n(ch, count).collect() } -/// Truncate a URL to fit within `max_width` display columns. -/// If the URL fits, it is returned as-is. -/// Otherwise it is cut and an `…` (U+2026) is appended. -pub fn truncate_url(url: &str, max_width: usize) -> String { - let width = display_width(url); +/// Truncate to fit within `max_width` display columns, appending `…` (U+2026) +/// if anything was cut. Counts display columns, not bytes or chars, so a line +/// of CJK or emoji lands in the right column. +pub fn truncate_to_width(s: &str, max_width: usize) -> String { + let width = display_width(s); if width <= max_width || max_width < 4 { - return url.to_string(); + return s.to_string(); } let mut result = String::new(); let mut w = 0; let limit = max_width - 1; // reserve 1 column for … - for ch in url.chars() { + for ch in s.chars() { let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); if w + cw > limit { break; @@ -41,6 +41,11 @@ pub fn truncate_url(url: &str, max_width: usize) -> String { result } +/// Truncate a URL to fit within `max_width` display columns. +pub fn truncate_url(url: &str, max_width: usize) -> String { + truncate_to_width(url, max_width) +} + /// Pad a string to a given display width with spaces. pub fn pad_to_width(s: &str, width: usize, align: Alignment) -> String { let current = display_width(s); @@ -147,3 +152,43 @@ mod fence_tests { assert!(!f.feed("text")); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_to_width_leaves_short_strings_alone() { + assert_eq!(truncate_to_width("abc", 10), "abc"); + assert_eq!(truncate_to_width("", 10), ""); + } + + /// Byte slicing here used to abort the process: `mdx diff` on any document + /// containing a wide character panicked on a non-char-boundary index. + #[test] + fn test_truncate_to_width_never_splits_a_character() { + for width in 0..12 { + let out = truncate_to_width("✅✅✅✅✅✅", width); + assert!( + out.chars().count() * 3 >= out.len(), + "produced invalid UTF-8" + ); + } + assert_eq!(truncate_to_width("日本語テキスト", 6), "日本\u{2026}"); + } + + /// Wide characters occupy two columns, so counting chars or bytes puts the + /// next column in the wrong place. + #[test] + fn test_truncate_to_width_counts_display_columns() { + assert!(display_width(&truncate_to_width("✅✅✅✅✅✅", 8)) <= 8); + assert!(display_width(&truncate_to_width("abcdefghij", 8)) <= 8); + assert!(display_width(&truncate_to_width("日本語テキスト日本語", 10)) <= 10); + } + + #[test] + fn test_truncate_to_width_marks_the_cut() { + assert!(truncate_to_width("abcdefghij", 5).ends_with('\u{2026}')); + assert!(!truncate_to_width("abc", 5).ends_with('\u{2026}')); + } +} diff --git a/tests/basic_test.rs b/tests/basic_test.rs index 81574d7..6effe55 100644 --- a/tests/basic_test.rs +++ b/tests/basic_test.rs @@ -2066,3 +2066,64 @@ fn test_export_pdf_square_backgrounds_are_not_rasterized() { ); assert!(bytes.windows(4).any(|w| w == b"%PDF")); } + +// ── diff ───────────────────────────────────────────────────────────── + +fn run_diff(a: &str, b: &str, extra: &[&str]) -> std::process::Output { + let fa = write_tmp("diff-a.md", a); + let fb = write_tmp("diff-b.md", b); + let out = Command::new(env!("CARGO_BIN_EXE_mdx")) + .arg("diff") + .args(extra) + .arg(&fa) + .arg(&fb) + .env("NO_COLOR", "1") + .output() + .expect("Failed to execute mdx"); + let _ = std::fs::remove_file(&fa); + let _ = std::fs::remove_file(&fb); + out +} + +/// Side-by-side truncation byte-sliced its input, so a wide character crossing +/// the column boundary aborted the process -- and `panic = "abort"` makes that +/// a SIGABRT with a core dump, not a clean error. +#[test] +fn test_diff_survives_wide_characters() { + for (a, b) in [ + ( + "# A\n\n\u{2705} \u{65e5}\u{672c}\u{8a9e} text\n", + "# B\n\n\u{2705} other \u{1f389}\n", + ), + (&"\u{2705}".repeat(200), "plain\n"), + ( + "caf\u{e9} \u{3a9}\u{3bc}\u{3ad}\u{3b3}\u{3b1}\n", + "\u{41f}\u{440}\u{438}\u{432}\u{435}\u{442}\n", + ), + ] { + let out = run_diff(a, b, &[]); + assert!( + out.status.success(), + "diff aborted on wide characters: {}", + String::from_utf8_lossy(&out.stderr) + ); + } +} + +#[test] +fn test_diff_handles_empty_and_identical_files() { + assert!(run_diff("", "# B\n", &[]).status.success()); + assert!(run_diff("# Same\n", "# Same\n", &[]).status.success()); + assert!(run_diff("", "", &[]).status.success()); +} + +#[test] +fn test_diff_unified_mode_works() { + let out = run_diff( + "# A\n\n\u{2705} wide\n", + "# B\n\n\u{2705} wide\n", + &["--unified"], + ); + assert!(out.status.success()); + assert!(!String::from_utf8_lossy(&out.stdout).is_empty()); +}