| 1 | //! Newline-boundary gate for streaming text. |
| 2 | //! |
| 3 | //! `LineBuffer` is an upstream-of-the-chunker safety layer that holds back any |
| 4 | //! text after the LAST `\n` until the next newline arrives. This prevents |
| 5 | //! partial multi-character markdown — most importantly partial code fences |
| 6 | //! (` ``` `) whose meaning flips depending on what follows on the same line — |
| 7 | //! from ever becoming visible state in the renderer. |
| 8 | //! |
| 9 | //! Mental model: |
| 10 | //! - `push(delta)` appends raw stream text to an internal pending buffer. |
| 11 | //! - `take_committable()` returns only the prefix up to and including the |
| 12 | //! LAST `\n` and clears that prefix. Whatever follows the last `\n` stays |
| 13 | //! in the buffer for the next push. |
| 14 | //! - `flush()` returns whatever is left, used at end-of-stream when the model |
| 15 | //! signals the turn is done. (The contract upstream of the chunker is that |
| 16 | //! only complete-line text is committed; `flush()` is the explicit escape |
| 17 | //! hatch when we know no more text will arrive.) |
| 18 | //! |
| 19 | //! See `cx5_chx5_newline_gate.md` in the task brief for full rationale. |
| 20 | |
| 21 | /// Holds streaming text until a newline boundary is reached. |
| 22 | /// |
| 23 | /// This is upstream of [`StreamChunker`](super::commit_tick::StreamChunker) |
| 24 | /// in the streaming pipeline: |
| 25 | /// |
| 26 | /// ```text |
| 27 | /// raw delta -> LineBuffer.push -> take_committable -> StreamChunker.push_delta -> commit tick |
| 28 | /// ``` |
| 29 | /// |
| 30 | /// The chunker also enforces a "drain-up-to-last-newline" rule on its pending |
| 31 | /// buffer, but `LineBuffer` exists as a *separate* layer so that: |
| 32 | /// 1. The contract is explicit and locally testable. |
| 33 | /// 2. Future downstream consumers (e.g. live preview that renders queued lines |
| 34 | /// optimistically) cannot accidentally see a partial fence. |
| 35 | /// 3. End-of-turn flush semantics are owned by the gate, not the policy. |
| 36 | #[derive(Debug, Default, Clone)] |
| 37 | pub struct LineBuffer { |
| 38 | /// Pending text not yet released because no terminating `\n` has been seen |
| 39 | /// since the last commit. |
| 40 | pending: String, |
| 41 | } |
| 42 | |
| 43 | impl LineBuffer { |
| 44 | /// Create an empty buffer. |
| 45 | pub fn new() -> Self { |
| 46 | Self::default() |
| 47 | } |
| 48 | |
| 49 | /// Append a raw delta. |
| 50 | pub fn push(&mut self, delta: &str) { |
| 51 | if delta.is_empty() { |
| 52 | return; |
| 53 | } |
| 54 | self.pending.push_str(delta); |
| 55 | } |
| 56 | |
| 57 | /// Return the prefix of the pending buffer up to and including the LAST |
| 58 | /// `\n`. Whatever follows that newline (if anything) stays buffered. |
| 59 | /// |
| 60 | /// Returns an empty string when the buffer is empty or contains no |
| 61 | /// newline yet — callers can treat the empty-string case as "nothing |
| 62 | /// committable on this push". |
| 63 | pub fn take_committable(&mut self) -> String { |
| 64 | let Some(last_nl) = self.pending.rfind('\n') else { |
| 65 | return String::new(); |
| 66 | }; |
| 67 | // Drain everything up to and including the last newline. The remaining |
| 68 | // tail (post-newline) stays in `pending` and is concatenated with the |
| 69 | // next `push` before the next commit decision is made. |
| 70 | self.pending.drain(..=last_nl).collect() |
| 71 | } |
| 72 | |
| 73 | /// Return whatever is left in the buffer, even if it is not newline |
| 74 | /// terminated. Used when the stream ends so we don't strand the final |
| 75 | /// partial line. |
| 76 | pub fn flush(&mut self) -> String { |
| 77 | std::mem::take(&mut self.pending) |
| 78 | } |
| 79 | |
| 80 | /// Whether the buffer holds any uncommitted text. |
| 81 | pub fn is_empty(&self) -> bool { |
| 82 | self.pending.is_empty() |
| 83 | } |
| 84 | |
| 85 | /// Length of the pending tail in bytes (testing/observability). |
| 86 | pub fn pending_len(&self) -> usize { |
| 87 | self.pending.len() |
| 88 | } |
| 89 | |
| 90 | /// Reset the buffer (e.g. on stream restart). |
| 91 | pub fn reset(&mut self) { |
| 92 | self.pending.clear(); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | #[cfg(test)] |
| 97 | mod tests { |
| 98 | use super::*; |
| 99 | |
| 100 | #[test] |
| 101 | fn push_without_newline_holds_everything() { |
| 102 | // Cornerstone invariant: nothing escapes the gate until a newline |
| 103 | // terminates the line. This is what protects partial code fences |
| 104 | // (e.g. ``` arriving in chunk N, language tag in chunk N+1). |
| 105 | let mut buf = LineBuffer::new(); |
| 106 | buf.push("hello"); |
| 107 | assert_eq!(buf.take_committable(), ""); |
| 108 | assert_eq!(buf.pending_len(), 5); |
| 109 | assert!(!buf.is_empty()); |
| 110 | } |
| 111 | |
| 112 | #[test] |
| 113 | fn push_with_trailing_partial_returns_only_prefix() { |
| 114 | let mut buf = LineBuffer::new(); |
| 115 | buf.push("hello\nwo"); |
| 116 | assert_eq!(buf.take_committable(), "hello\n"); |
| 117 | // Tail is held for next call. |
| 118 | assert_eq!(buf.pending_len(), 2); |
| 119 | assert!(!buf.is_empty()); |
| 120 | } |
| 121 | |
| 122 | #[test] |
| 123 | fn next_push_is_concatenated_with_held_tail() { |
| 124 | let mut buf = LineBuffer::new(); |
| 125 | buf.push("hello\nwo"); |
| 126 | assert_eq!(buf.take_committable(), "hello\n"); |
| 127 | // The held "wo" is concatenated with "rld\n", and the whole line |
| 128 | // becomes committable. |
| 129 | buf.push("rld\n"); |
| 130 | assert_eq!(buf.take_committable(), "world\n"); |
| 131 | assert!(buf.is_empty()); |
| 132 | } |
| 133 | |
| 134 | #[test] |
| 135 | fn flush_returns_unterminated_tail() { |
| 136 | let mut buf = LineBuffer::new(); |
| 137 | buf.push("trailing without newline"); |
| 138 | // No newline → nothing committable. |
| 139 | assert_eq!(buf.take_committable(), ""); |
| 140 | // End-of-stream flush returns it raw. |
| 141 | assert_eq!(buf.flush(), "trailing without newline"); |
| 142 | assert!(buf.is_empty()); |
| 143 | } |
| 144 | |
| 145 | #[test] |
| 146 | fn flush_is_empty_when_buffer_drained() { |
| 147 | let mut buf = LineBuffer::new(); |
| 148 | buf.push("a\n"); |
| 149 | assert_eq!(buf.take_committable(), "a\n"); |
| 150 | assert_eq!(buf.flush(), ""); |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn multi_line_burst_returns_prefix_through_last_newline() { |
| 155 | // Multiple newlines in one push: the entire prefix up through the |
| 156 | // last newline is committable in one go; only the unterminated tail |
| 157 | // is held. |
| 158 | let mut buf = LineBuffer::new(); |
| 159 | buf.push("a\nb\nc\nd"); |
| 160 | assert_eq!(buf.take_committable(), "a\nb\nc\n"); |
| 161 | assert_eq!(buf.pending_len(), 1); |
| 162 | // Finishing "d" with a newline releases it on the next take. |
| 163 | buf.push("\n"); |
| 164 | assert_eq!(buf.take_committable(), "d\n"); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn partial_code_fence_never_escapes_the_gate() { |
| 169 | // Acceptance scenario from CX#5: a fenced code block whose opener |
| 170 | // arrives split across deltas must never expose "foo```rust" without |
| 171 | // a terminating newline. We assert that on every intermediate |
| 172 | // commit, the *committed* text either contains a newline or is empty |
| 173 | // — i.e. the pre-language partial fence never leaks. |
| 174 | let mut buf = LineBuffer::new(); |
| 175 | |
| 176 | // Chunk 1: a paragraph fragment ending with the fence opener. |
| 177 | buf.push("foo```"); |
| 178 | let c1 = buf.take_committable(); |
| 179 | assert!( |
| 180 | c1.is_empty() || c1.ends_with('\n'), |
| 181 | "partial fence leaked: {c1:?}" |
| 182 | ); |
| 183 | assert!( |
| 184 | !c1.contains("foo```"), |
| 185 | "fence opener escaped without newline: {c1:?}" |
| 186 | ); |
| 187 | |
| 188 | // Chunk 2: language tag + start of body. The fence line is now |
| 189 | // newline-terminated, so it can commit; the post-newline body is |
| 190 | // held. |
| 191 | buf.push("rust\nlet x"); |
| 192 | let c2 = buf.take_committable(); |
| 193 | assert!( |
| 194 | c2.ends_with('\n'), |
| 195 | "expected newline-terminated commit: {c2:?}" |
| 196 | ); |
| 197 | assert_eq!(c2, "foo```rust\n"); |
| 198 | |
| 199 | // Chunk 3: rest of body and the fence closer. |
| 200 | buf.push("= 1;\n```\n"); |
| 201 | let c3 = buf.take_committable(); |
| 202 | assert_eq!(c3, "let x= 1;\n```\n"); |
| 203 | assert!(buf.is_empty()); |
| 204 | } |
| 205 | |
| 206 | #[test] |
| 207 | fn empty_push_is_a_noop() { |
| 208 | let mut buf = LineBuffer::new(); |
| 209 | buf.push(""); |
| 210 | assert!(buf.is_empty()); |
| 211 | assert_eq!(buf.take_committable(), ""); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn reset_clears_pending_tail() { |
| 216 | let mut buf = LineBuffer::new(); |
| 217 | buf.push("partial"); |
| 218 | assert_eq!(buf.pending_len(), 7); |
| 219 | buf.reset(); |
| 220 | assert!(buf.is_empty()); |
| 221 | assert_eq!(buf.flush(), ""); |
| 222 | } |
| 223 | } |
| 224 |