From 3e0a263eb01f03e22704413beb9081bc25510e21 Mon Sep 17 00:00:00 2001 From: SEPURI-SAI-KRISHNA Date: Wed, 5 Aug 2026 16:19:17 +0530 Subject: [PATCH] fix: burrows_wheeler_transform round-trip corruption and unicode panic --- src/string/burrows_wheeler_transform.rs | 88 ++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/src/string/burrows_wheeler_transform.rs b/src/string/burrows_wheeler_transform.rs index 071b16e9f56..aeeb52b8cc1 100644 --- a/src/string/burrows_wheeler_transform.rs +++ b/src/string/burrows_wheeler_transform.rs @@ -1,17 +1,30 @@ +//! Burrows-Wheeler transform. +//! +//! The transform sorts every rotation of the input and emits the last character +//! of each. Inversion relies on that rotation order being *the same total order* +//! the inverse uses to sort the encoded characters, so both directions here order +//! by `char` (equivalently, by code point). +//! +//! Wikipedia reference: + +/// Returns the transformed string together with the row index of the original input. pub fn burrows_wheeler_transform(input: &str) -> (String, usize) { - let len = input.len(); + // Rotate over `char`s: slicing `&str` by an arbitrary index splits multi-byte + // characters and panics. + let chars: Vec = input.chars().collect(); + let len = chars.len(); let mut table = Vec::::with_capacity(len); for i in 0..len { - table.push(input[i..].to_owned() + &input[..i]); + table.push(chars[i..].iter().chain(&chars[..i]).collect()); } - table.sort_by_key(|a| a.to_lowercase()); + table.sort(); let mut encoded = String::new(); let mut index: usize = 0; for (i, item) in table.iter().enumerate().take(len) { encoded.push(item.chars().last().unwrap()); - if item.eq(&input) { + if item == input { index = i; } } @@ -19,13 +32,14 @@ pub fn burrows_wheeler_transform(input: &str) -> (String, usize) { (encoded, index) } +/// Reconstructs the original string from a transform and its row index. pub fn inv_burrows_wheeler_transform>(input: (T, usize)) -> String { - let len = input.0.as_ref().len(); - let mut table = Vec::<(usize, char)>::with_capacity(len); - for i in 0..len { - table.push((i, input.0.as_ref().chars().nth(i).unwrap())); - } + let chars: Vec = input.0.as_ref().chars().collect(); + let len = chars.len(); + // `sort_by_key` is stable, which is what keeps equal characters in their + // original relative order — the property the reconstruction walk depends on. + let mut table: Vec<(usize, char)> = chars.into_iter().enumerate().collect(); table.sort_by_key(|a| a.1); let mut decoded = String::new(); @@ -114,4 +128,60 @@ mod tests { "" ); } + + /// Regression: the forward transform sorted rotations case-insensitively + /// while the inverse sorted characters by code point, so any string mixing + /// cases round-tripped to garbage ("Hello" came back as "elloe"). + #[test] + fn mixed_case() { + for text in [ + "Hello", + "Mississippi", + "AaAa", + "Test", + "aAbB", + "Rust Lang", + "The Algorithms", + ] { + assert_eq!( + inv_burrows_wheeler_transform(burrows_wheeler_transform(text)), + text + ); + } + } + + /// Regression: rotations were built by slicing `&str` at byte offsets, which + /// panics as soon as an index lands inside a multi-byte character. + #[test] + fn unicode() { + for text in [ + "café au lait", + "日本語のテキスト", + "naïve", + "ábcábc", + "🎉party🎉", + "Ünïcödé Mïx", + ] { + assert_eq!( + inv_burrows_wheeler_transform(burrows_wheeler_transform(text)), + text + ); + } + } + + #[test] + fn single_character() { + assert_eq!(burrows_wheeler_transform("a"), ("a".to_owned(), 0)); + assert_eq!(inv_burrows_wheeler_transform(("a", 0usize)), "a"); + } + + #[test] + fn repeated_characters() { + for text in ["aaaa", "abab", "aaaab"] { + assert_eq!( + inv_burrows_wheeler_transform(burrows_wheeler_transform(text)), + text + ); + } + } }