Skip to main content

vtcode_commons/
utf8_stream.rs

1//! Incremental UTF-8 decoding across chunk boundaries (network streams, pipe reads).
2
3use std::borrow::Cow;
4
5/// Incrementally decodes a byte stream into UTF-8 text.
6///
7/// Chunks can split a multibyte code point (CJK, emoji, accented characters,
8/// smart quotes) across boundaries. Decoding each chunk independently with
9/// `String::from_utf8_lossy` corrupts such code points into `U+FFFD`
10/// replacement characters. This decoder buffers any trailing incomplete
11/// sequence until the rest of its bytes arrive, while still replacing genuinely
12/// invalid bytes with `U+FFFD` (matching `from_utf8_lossy` semantics). Call
13/// [`Utf8StreamDecoder::finish`] at end of stream so a truncated final sequence
14/// becomes `U+FFFD` instead of vanishing.
15#[derive(Debug, Default)]
16pub struct Utf8StreamDecoder {
17    pending: Vec<u8>,
18}
19
20impl Utf8StreamDecoder {
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Appends `bytes` and returns the decodable UTF-8 prefix. A trailing
26    /// incomplete multibyte sequence is retained for the next call.
27    pub fn push(&mut self, bytes: &[u8]) -> String {
28        let mut out = Vec::with_capacity(bytes.len());
29        self.push_bytes(bytes, &mut out);
30        // `push_bytes` only ever writes valid UTF-8, so this never allocates a lossy copy.
31        String::from_utf8(out).unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())
32    }
33
34    /// Like [`Utf8StreamDecoder::push`], but borrows `bytes` without copying when
35    /// nothing is pending and the chunk is valid UTF-8 (the common case for
36    /// process output), so hot read loops avoid a per-chunk allocation.
37    pub fn push_cow<'a>(&mut self, bytes: &'a [u8]) -> Cow<'a, str> {
38        if self.pending.is_empty()
39            && let Ok(text) = std::str::from_utf8(bytes)
40        {
41            return Cow::Borrowed(text);
42        }
43        Cow::Owned(self.push(bytes))
44    }
45
46    /// Appends `bytes` and writes the decodable UTF-8 prefix directly into
47    /// `out`. A trailing incomplete multibyte sequence is retained for the
48    /// next call.
49    ///
50    /// This avoids the intermediate `String` allocation that `push` creates
51    /// when the caller only needs bytes (e.g. feeding an SSE byte buffer).
52    pub fn push_bytes(&mut self, bytes: &[u8], out: &mut Vec<u8>) {
53        self.pending.extend_from_slice(bytes);
54        loop {
55            match std::str::from_utf8(&self.pending) {
56                Ok(text) => {
57                    out.extend_from_slice(text.as_bytes());
58                    self.pending.clear();
59                    break;
60                }
61                Err(err) => {
62                    let valid = err.valid_up_to();
63                    if let Some(valid_bytes) = self.pending.get(..valid) {
64                        // `valid_bytes` is guaranteed valid UTF-8 by `valid_up_to`.
65                        out.extend_from_slice(valid_bytes);
66                    }
67                    match err.error_len() {
68                        // Genuinely invalid sequence: emit replacement and skip it.
69                        Some(invalid_len) => {
70                            out.extend_from_slice("\u{FFFD}".as_bytes());
71                            let _ = self.pending.drain(..valid + invalid_len);
72                        }
73                        // Incomplete trailing sequence: keep it for the next push.
74                        None => {
75                            let _ = self.pending.drain(..valid);
76                            break;
77                        }
78                    }
79                }
80            }
81        }
82    }
83
84    /// Ends the stream: returns any retained incomplete sequence lossily
85    /// decoded (as `U+FFFD`), leaving the decoder empty.
86    pub fn finish(&mut self) -> String {
87        let pending = std::mem::take(&mut self.pending);
88        String::from_utf8_lossy(&pending).into_owned()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn push_bytes_matches_push_byte_by_byte() {
98        let full = "data: {\"hello\":\"world\"} xin chào 世界\n\n".as_bytes();
99        let mut dec_str = Utf8StreamDecoder::new();
100        let mut dec_bytes = Utf8StreamDecoder::new();
101        let mut joined = String::new();
102        for (i, &byte) in full.iter().enumerate() {
103            let mut out = Vec::new();
104            dec_bytes.push_bytes(std::slice::from_ref(&byte), &mut out);
105            let s = dec_str.push(std::slice::from_ref(&byte));
106            assert_eq!(out, s.clone().into_bytes(), "byte {i}: push_bytes must match push");
107            joined.push_str(&s);
108        }
109        assert_eq!(joined.as_bytes(), full);
110        assert!(dec_str.finish().is_empty());
111    }
112
113    #[test]
114    fn split_multibyte_is_held_until_complete() {
115        // U+00E9 = 0xC3 0xA9 split across chunks.
116        let mut dec = Utf8StreamDecoder::new();
117        assert!(dec.push(&[0xC3]).is_empty(), "incomplete multibyte should produce no output");
118        assert_eq!(dec.push(&[0xA9, b'h', b'i']), "\u{00E9}hi");
119    }
120
121    #[test]
122    fn invalid_bytes_become_replacement_characters() {
123        // 0xFF is never a valid UTF-8 lead byte.
124        let mut dec = Utf8StreamDecoder::new();
125        assert_eq!(dec.push(&[b'a', 0xFF, b'b']), "a\u{FFFD}b");
126    }
127
128    #[test]
129    fn push_cow_borrows_valid_chunks_and_buffers_splits() {
130        let mut dec = Utf8StreamDecoder::new();
131        assert!(matches!(dec.push_cow("plain 世".as_bytes()), Cow::Borrowed("plain 世")));
132        // `界` = E7 95 8C split across chunks: nothing until complete, then owned.
133        assert_eq!(dec.push_cow(&[0xE7, 0x95]), "");
134        let completed = dec.push_cow(&[0x8C, b'!']);
135        assert!(matches!(completed, Cow::Owned(_)));
136        assert_eq!(completed, "界!");
137        assert!(dec.finish().is_empty());
138    }
139
140    #[test]
141    fn finish_flushes_a_truncated_final_sequence() {
142        let mut dec = Utf8StreamDecoder::new();
143        // First two bytes of the three-byte `—` (0xE2 0x80 0x94).
144        assert_eq!(dec.push(&[b'x', 0xE2, 0x80]), "x");
145        assert_eq!(dec.finish(), "\u{FFFD}");
146        assert!(dec.finish().is_empty(), "finish leaves the decoder empty");
147    }
148}