Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 21 additions & 22 deletions src/diff.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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{:<col_width$}\x1b[0m | \x1b[1m{}\x1b[0m",
truncate(name_a, col_width),
truncate(name_b, col_width),
"\x1b[1m{}\x1b[0m | \x1b[1m{}\x1b[0m",
pad_to_width(
&truncate_to_width(name_a, col_width),
col_width,
Alignment::Left
),
truncate_to_width(name_b, col_width),
);
println!("{}", "-".repeat(term_width.min(col_width * 2 + 3)));

Expand All @@ -75,34 +81,27 @@ fn print_side_by_side<'a>(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{:<col_width$}\x1b[0m | \x1b[90m{}\x1b[0m",
left, right
"\x1b[90m{}\x1b[0m | \x1b[90m{}\x1b[0m",
pad_to_width(&left, col_width, Alignment::Left),
left
);
}
ChangeTag::Delete => {
let left = truncate(line, col_width);
println!("\x1b[31m{:<col_width$}\x1b[0m | ", left);
let left = truncate_to_width(line, col_width);
println!(
"\x1b[31m{}\x1b[0m | ",
pad_to_width(&left, col_width, Alignment::Left)
);
}
ChangeTag::Insert => {
println!(
"{:<col_width$} | \x1b[32m{}\x1b[0m",
"",
truncate(line, col_width)
"{} | \x1b[32m{}\x1b[0m",
" ".repeat(col_width),
truncate_to_width(line, col_width)
);
}
}
}
}

fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else if max > 3 {
format!("{}...", &s[..max - 3])
} else {
s[..max].to_string()
}
}
59 changes: 52 additions & 7 deletions src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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}'));
}
}
61 changes: 61 additions & 0 deletions tests/basic_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Loading