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}