text-fx 0.4.0

A collection of text processing utilities for Rust.
Documentation
/// Represents a chunk yielded by [`Utf8Iter`]: either a valid Unicode character or an invalid byte.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Utf8Chunk {
    /// A valid Unicode character.
    Valid(char),
    /// An invalid byte that could not be decoded as UTF-8.
    Invalid(u8),
}

/// An iterator over a byte slice that yields valid Unicode characters or invalid bytes.
///
/// This iterator attempts to decode UTF-8 from the input byte slice. For each step:
/// - If a valid UTF-8 character is found, it yields `Utf8Chunk::Valid(char)`.
/// - If an invalid byte is encountered, it yields `Utf8Chunk::Invalid(u8)` and advances by one byte.
///
/// This is useful for robustly processing possibly-invalid UTF-8 data.
///
/// # Examples
///
/// ```
/// use text_fx::utf8::{Utf8Iter, Utf8Chunk};
///
/// let bytes = b"he\xffllo";
/// let mut iter = Utf8Iter::new(bytes);
/// assert_eq!(iter.next(), Some(Utf8Chunk::Valid('h')));
/// assert_eq!(iter.next(), Some(Utf8Chunk::Valid('e')));
/// assert_eq!(iter.next(), Some(Utf8Chunk::Invalid(0xff)));
/// assert_eq!(iter.next(), Some(Utf8Chunk::Valid('l')));
/// ```
pub struct Utf8Iter<'a> {
    bytes: &'a [u8],
    pos: usize,
}

impl<'a> Utf8Iter<'a> {
    /// Create a new `Utf8Iter` from a byte slice.
    pub fn new(bytes: &'a [u8]) -> Self {
        Utf8Iter { bytes, pos: 0 }
    }
}

impl<'a> Iterator for Utf8Iter<'a> {
    type Item = Utf8Chunk;

    fn next(&mut self) -> Option<Self::Item> {
        if self.pos >= self.bytes.len() {
            return None;
        }

        let remaining = &self.bytes[self.pos..];

        match std::str::from_utf8(remaining) {
            Ok(valid) => {
                // All valid — take one char and go on
                let mut chars = valid.chars();
                if let Some(c) = chars.next() {
                    self.pos += c.len_utf8();
                    Some(Utf8Chunk::Valid(c))
                } else {
                    None
                }
            }
            Err(err) => {
                let valid_up_to = err.valid_up_to();
                if valid_up_to > 0 {
                    // SAFETY: guaranteed valid range
                    let valid_str =
                        unsafe { std::str::from_utf8_unchecked(&remaining[..valid_up_to]) };
                    let c = valid_str.chars().next().unwrap();
                    self.pos += c.len_utf8();
                    Some(Utf8Chunk::Valid(c))
                } else {
                    // First byte is invalid
                    let b = self.bytes[self.pos];
                    self.pos += 1;
                    Some(Utf8Chunk::Invalid(b))
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_utf8_iter() {
        let bytes = b"hello \xF0\x90world\xC3(";

        for chunk in Utf8Iter::new(bytes) {
            match chunk {
                Utf8Chunk::Valid(c) => print!("{}", c),
                Utf8Chunk::Invalid(b) => print!("�(0x{:02X})", b),
            }
        }
    }
}