Skip to main content

relux_runtime/observe/structured/
utf8_stream.rs

1//! Streaming UTF-8 decoder for byte streams arriving in arbitrary chunks.
2//!
3//! PTY reads land at unpredictable byte boundaries: a 4-byte UTF-8 codepoint
4//! (for example U+1F389, encoded as `F0 9F 8E 89`) can be split across two
5//! reads. Decoding each chunk independently with `from_utf8_lossy` would
6//! replace both halves with `U+FFFD` and lose the codepoint.
7//!
8//! `Utf8Stream` keeps up to 3 trailing bytes of an unfinished sequence as
9//! carryover; the next chunk is prepended with that carryover before decoding.
10//! Genuinely invalid sequences (not partial) emit a single `U+FFFD` and the
11//! stream resynchronizes on the next valid byte.
12
13#[derive(Debug, Default)]
14pub struct Utf8Stream {
15    pending: Vec<u8>,
16}
17
18impl Utf8Stream {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    /// Feed a chunk of bytes; return everything that decoded into complete
24    /// codepoints. Up to 3 trailing bytes belonging to a partial sequence are
25    /// kept internally and prepended to the next call.
26    pub fn feed(&mut self, chunk: &[u8]) -> String {
27        // Combine carryover with the new chunk.
28        let mut bytes = std::mem::take(&mut self.pending);
29        bytes.extend_from_slice(chunk);
30
31        let mut out = String::new();
32        let mut start = 0;
33        loop {
34            let slice = &bytes[start..];
35            match std::str::from_utf8(slice) {
36                Ok(s) => {
37                    out.push_str(s);
38                    return out;
39                }
40                Err(e) => {
41                    let valid_up_to = e.valid_up_to();
42                    // Safety: from_utf8 just told us this prefix is valid UTF-8.
43                    out.push_str(unsafe { std::str::from_utf8_unchecked(&slice[..valid_up_to]) });
44                    match e.error_len() {
45                        // None means the trailing bytes look like the start of
46                        // a multi-byte sequence that hasn't fully arrived. Hold
47                        // them as carryover for the next feed.
48                        None => {
49                            self.pending = slice[valid_up_to..].to_vec();
50                            return out;
51                        }
52                        // A genuine encoding error of `len` bytes: emit the
53                        // replacement character and keep going.
54                        Some(len) => {
55                            out.push('\u{FFFD}');
56                            start += valid_up_to + len;
57                        }
58                    }
59                }
60            }
61        }
62    }
63
64    /// Number of carryover bytes currently held back (an incomplete trailing
65    /// multi-byte sequence waiting for completion). Always `<= 3`.
66    pub fn pending_len(&self) -> usize {
67        self.pending.len()
68    }
69
70    /// Flush any carryover at end-of-stream. Trailing partial bytes that never
71    /// completed are treated as invalid and become a single `U+FFFD`.
72    pub fn flush(&mut self) -> String {
73        if self.pending.is_empty() {
74            String::new()
75        } else {
76            self.pending.clear();
77            "\u{FFFD}".to_string()
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn ascii_passes_through() {
88        let mut s = Utf8Stream::new();
89        assert_eq!(s.feed(b"hello"), "hello");
90        assert_eq!(s.feed(b" world"), " world");
91        assert!(s.pending.is_empty());
92    }
93
94    #[test]
95    fn split_4byte_codepoint_round_trips() {
96        // U+1F389 PARTY POPPER, encoded as F0 9F 8E 89.
97        let mut s = Utf8Stream::new();
98        let first = s.feed(&[0xF0, 0x9F]);
99        // Both bytes are partial; nothing emitted yet.
100        assert_eq!(first, "");
101        assert_eq!(s.pending, vec![0xF0, 0x9F]);
102
103        let second = s.feed(&[0x8E, 0x89]);
104        // Now the codepoint is complete and emitted exactly once.
105        assert_eq!(second, "\u{1F389}");
106        assert!(s.pending.is_empty());
107    }
108
109    #[test]
110    fn split_3byte_codepoint_round_trips() {
111        // U+2122 TRADE MARK SIGN, encoded as E2 84 A2.
112        let mut s = Utf8Stream::new();
113        assert_eq!(s.feed(&[0xE2]), "");
114        assert_eq!(s.feed(&[0x84, 0xA2]), "\u{2122}");
115    }
116
117    #[test]
118    fn invalid_byte_emits_replacement_and_recovers() {
119        let mut s = Utf8Stream::new();
120        // 0xFF is never valid in UTF-8; the surrounding ASCII must still come through.
121        let out = s.feed(&[b'a', 0xFF, b'b']);
122        assert_eq!(out, "a\u{FFFD}b");
123        assert!(s.pending.is_empty());
124    }
125
126    #[test]
127    fn carryover_is_bounded() {
128        let mut s = Utf8Stream::new();
129        // Feed only the first byte of a 4-byte sequence many times - pending
130        // must never exceed 3 bytes.
131        for _ in 0..10 {
132            s.feed(&[0xF0]);
133            assert!(s.pending.len() <= 3, "pending grew beyond 3 bytes");
134        }
135    }
136
137    #[test]
138    fn lone_continuation_byte_emits_replacement() {
139        let mut s = Utf8Stream::new();
140        // 0x8E by itself is a continuation byte with no leader.
141        let out = s.feed(&[0x8E]);
142        assert_eq!(out, "\u{FFFD}");
143        assert!(s.pending.is_empty());
144    }
145
146    #[test]
147    fn pending_len_reports_carryover_size() {
148        let mut s = Utf8Stream::new();
149        assert_eq!(s.pending_len(), 0);
150        s.feed(b"hello");
151        assert_eq!(s.pending_len(), 0);
152        // First two bytes of U+1F389 (F0 9F 8E 89) - incomplete.
153        s.feed(&[0xF0, 0x9F]);
154        assert_eq!(s.pending_len(), 2);
155        // Finish the codepoint - pending drains.
156        s.feed(&[0x8E, 0x89]);
157        assert_eq!(s.pending_len(), 0);
158    }
159
160    #[test]
161    fn flush_emits_replacement_for_pending_bytes() {
162        let mut s = Utf8Stream::new();
163        s.feed(&[0xF0, 0x9F]);
164        // Stream ended mid-codepoint; flush turns the pending bytes into a
165        // single replacement char.
166        assert_eq!(s.flush(), "\u{FFFD}");
167        assert_eq!(s.flush(), "");
168    }
169}