Skip to main content

docspec_core/
skip_empty_blocks.rs

1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2
3use crate::{Event, EventSource, Result};
4
5/// A streaming `EventSource` adapter that suppresses empty `Heading`, `BlockQuote`, and
6/// `Paragraph` Start/End pairs from the wrapped source.
7///
8/// The adapter uses a **1-event look-back** (hold-1) algorithm. When it sees a candidate
9/// skippable `Start*` event (`StartHeading`, `StartBlockQuote`, or `StartParagraph`), it
10/// buffers that event and peeks the next event from the inner source. If the next event is
11/// the matching `End*` (i.e., the block is empty), both events are dropped and the adapter
12/// keeps draining iteratively until it finds an event to emit. If the next event is something
13/// else, the buffered `Start*` is emitted immediately and the "something else" is stashed as
14/// `pending` for the next call. Memory is O(1) — at most two `Event` values are held at any
15/// time. Stack is O(1) — the implementation is a single `loop` with no recursion, so an
16/// arbitrarily long run of empty blocks consumes constant stack regardless of input size.
17///
18/// # Skip Set
19///
20/// Exactly three Start/End pairs are matched:
21///
22/// - `Event::StartHeading { .. }` ↔ `Event::EndHeading`
23/// - `Event::StartBlockQuote { .. }` ↔ `Event::EndBlockQuote`
24/// - `Event::StartParagraph { .. }` ↔ `Event::EndParagraph`
25///
26/// No other variants are suppressed. Empty `StartTable`, `StartOrderedListItem`, and all
27/// other containers pass through unchanged.
28///
29/// # Asymmetric API
30///
31/// `docspec-cli` and `docspec-http` apply this filter automatically by default.
32/// Library users opt in by wrapping their `EventSource` explicitly:
33/// `SkipEmptyBlocks::new(my_reader)`.
34///
35/// # Known Limitations
36///
37/// **No cascading**: an outer container that becomes empty *because* its inner contents were
38/// filtered is preserved. Example: `StartBlockQuote → StartParagraph → EndParagraph → EndBlockQuote`
39/// produces `StartBlockQuote → EndBlockQuote` (the inner empty paragraph pair is dropped, but the
40/// outer block quote is preserved). A subsequent pass would be needed to suppress the outer.
41///
42/// **Empty table cells**: an empty table cell containing only `StartParagraph → EndParagraph`
43/// will have the inner pair dropped, leaving the cell with no child events. The cell itself is
44/// preserved (table cells are not in the skip set).
45///
46/// **Fail-fast**: if the inner source returns `Err` while a `Start*` is buffered, the error
47/// propagates immediately and the buffered `Start*` is dropped silently. The stream is considered
48/// terminated; no partial recovery is attempted.
49///
50/// # Example
51///
52/// ```
53/// use docspec_core::{Event, EventSource, Result, SkipEmptyBlocks};
54///
55/// struct Replay {
56///     events: std::vec::IntoIter<Event>,
57/// }
58/// impl Replay {
59///     fn new(events: Vec<Event>) -> Self {
60///         Self { events: events.into_iter() }
61///     }
62/// }
63/// impl EventSource for Replay {
64///     fn next_event(&mut self) -> Result<Option<Event>> {
65///         Ok(self.events.next())
66///     }
67/// }
68///
69/// // An empty heading followed by a heading with text:
70/// let inner = Replay::new(vec![
71///     Event::StartHeading { id: None, level: 1 },
72///     Event::EndHeading,
73///     Event::StartHeading { id: None, level: 2 },
74///     Event::Text { content: String::from("Hello") },
75///     Event::EndHeading,
76/// ]);
77/// let mut filtered = SkipEmptyBlocks::new(inner);
78///
79/// // The empty H1 is dropped; the H2 with text passes through.
80/// assert_eq!(
81///     filtered.next_event().unwrap(),
82///     Some(Event::StartHeading { id: None, level: 2 }),
83/// );
84/// assert_eq!(
85///     filtered.next_event().unwrap(),
86///     Some(Event::Text { content: String::from("Hello") }),
87/// );
88/// assert_eq!(filtered.next_event().unwrap(), Some(Event::EndHeading));
89/// assert_eq!(filtered.next_event().unwrap(), None);
90/// ```
91pub struct SkipEmptyBlocks<S: EventSource> {
92    inner: S,
93    /// Holds a candidate skippable `Start*` (`StartHeading`, `StartBlockQuote`, or
94    /// `StartParagraph`) while we peek the next event to decide drop-or-flush.
95    buffered: Option<Event>,
96    /// Holds a non-skippable event that arrived while we were flushing the previous
97    /// `buffered` `Start*`. The next call to `next_event` returns this before pulling
98    /// from `inner` again.
99    pending: Option<Event>,
100}
101
102impl<S: EventSource> SkipEmptyBlocks<S> {
103    /// Wraps the given source so that empty `Heading`, `BlockQuote`, and `Paragraph`
104    /// Start/End pairs are suppressed in the emitted stream.
105    #[inline]
106    pub fn new(inner: S) -> Self {
107        Self {
108            inner,
109            buffered: None,
110            pending: None,
111        }
112    }
113}
114
115impl<S: EventSource> EventSource for SkipEmptyBlocks<S> {
116    #[inline]
117    fn next_event(&mut self) -> Result<Option<Event>> {
118        loop {
119            // 1. Drain `pending` first (already decided to emit on a previous call).
120            if let Some(pending) = self.pending.take() {
121                return Ok(Some(pending));
122            }
123            // 2. If a buffered `Start*` exists, peek next from inner.
124            //    NOTE: `?` propagation here means an `Err` from inner while a
125            //    `Start*` is buffered surfaces IMMEDIATELY on this same call; the
126            //    buffered `Start*` is dropped. This is intentional and matches the
127            //    project's "Fail Fast" principle (MANIFESTO.md).
128            if let Some(buffered) = self.buffered.take() {
129                match self.inner.next_event()? {
130                    Some(next) if is_matching_end(&buffered, &next) => {
131                        // Empty block detected: drop BOTH and keep draining iteratively.
132                        continue;
133                    }
134                    Some(next) if is_skippable_start(&next) => {
135                        // Emit `buffered` now; the new `Start*` becomes the new buffer.
136                        self.buffered = Some(next);
137                        return Ok(Some(buffered));
138                    }
139                    Some(next) => {
140                        // Emit `buffered` now; stash `next` as pending for the next call.
141                        self.pending = Some(next);
142                        return Ok(Some(buffered));
143                    }
144                    None => {
145                        // Truncated stream: emit buffered `Start*`; subsequent call returns None.
146                        return Ok(Some(buffered));
147                    }
148                }
149            }
150            // 3. No buffer, no pending. Pull from inner.
151            match self.inner.next_event()? {
152                Some(event) if is_skippable_start(&event) => {
153                    self.buffered = Some(event);
154                    // Loop iterates: re-enter the buffered branch above to peek the next event.
155                }
156                other => return Ok(other),
157            }
158        }
159    }
160}
161
162// Returns true if `event` is a candidate start event for empty-block suppression.
163fn is_skippable_start(event: &Event) -> bool {
164    matches!(
165        event,
166        Event::StartHeading { .. } | Event::StartBlockQuote { .. } | Event::StartParagraph { .. }
167    )
168}
169
170// Returns true if `end` is the matching close event for the given `start`.
171fn is_matching_end(start: &Event, end: &Event) -> bool {
172    matches!(
173        (start, end),
174        (Event::StartHeading { .. }, Event::EndHeading)
175            | (Event::StartBlockQuote { .. }, Event::EndBlockQuote)
176            | (Event::StartParagraph { .. }, Event::EndParagraph)
177    )
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::{Event, EventSource, Result};
184
185    /// Minimal in-memory `EventSource` for testing.
186    struct Replay {
187        events: alloc::vec::IntoIter<Event>,
188        error_at_end: bool,
189    }
190
191    impl Replay {
192        fn new(events: alloc::vec::Vec<Event>) -> Self {
193            Self {
194                events: events.into_iter(),
195                error_at_end: false,
196            }
197        }
198
199        fn with_terminal_error(events: alloc::vec::Vec<Event>) -> Self {
200            Self {
201                events: events.into_iter(),
202                error_at_end: true,
203            }
204        }
205    }
206
207    impl EventSource for Replay {
208        fn next_event(&mut self) -> Result<Option<Event>> {
209            if let Some(e) = self.events.next() {
210                Ok(Some(e))
211            } else if self.error_at_end {
212                self.error_at_end = false;
213                Err(crate::Error::Other {
214                    message: "simulated".into(),
215                })
216            } else {
217                Ok(None)
218            }
219        }
220    }
221
222    fn drain<S>(mut src: S) -> alloc::vec::Vec<Event>
223    where
224        S: EventSource,
225    {
226        let mut out = alloc::vec::Vec::new();
227        while let Some(e) = src.next_event().expect("unexpected error") {
228            out.push(e);
229        }
230        out
231    }
232
233    #[test]
234    fn empty_heading_is_dropped() {
235        let replay = Replay::new(alloc::vec![
236            Event::StartHeading { id: None, level: 1 },
237            Event::EndHeading,
238        ]);
239        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
240    }
241
242    #[test]
243    fn empty_block_quote_is_dropped() {
244        let replay = Replay::new(alloc::vec![
245            Event::StartBlockQuote { id: None },
246            Event::EndBlockQuote,
247        ]);
248        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
249    }
250
251    #[test]
252    fn empty_paragraph_is_dropped() {
253        let replay = Replay::new(alloc::vec![
254            Event::StartParagraph {
255                alignment: None,
256                id: None
257            },
258            Event::EndParagraph,
259        ]);
260        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
261    }
262
263    #[test]
264    fn non_empty_heading_is_preserved() {
265        let input = alloc::vec![
266            Event::StartHeading { id: None, level: 2 },
267            Event::Text {
268                content: "h".into()
269            },
270            Event::EndHeading,
271        ];
272        let replay = Replay::new(input.clone());
273        assert_eq!(drain(SkipEmptyBlocks::new(replay)), input);
274    }
275
276    #[test]
277    fn non_empty_paragraph_is_preserved() {
278        let input = alloc::vec![
279            Event::StartParagraph {
280                alignment: None,
281                id: None
282            },
283            Event::Text {
284                content: "p".into()
285            },
286            Event::EndParagraph,
287        ];
288        let replay = Replay::new(input.clone());
289        assert_eq!(drain(SkipEmptyBlocks::new(replay)), input);
290    }
291
292    #[test]
293    fn non_empty_block_quote_is_preserved() {
294        let input = alloc::vec![
295            Event::StartBlockQuote { id: None },
296            Event::StartParagraph {
297                alignment: None,
298                id: None
299            },
300            Event::Text {
301                content: "q".into()
302            },
303            Event::EndParagraph,
304            Event::EndBlockQuote,
305        ];
306        let replay = Replay::new(input.clone());
307        assert_eq!(drain(SkipEmptyBlocks::new(replay)), input);
308    }
309
310    #[test]
311    fn consecutive_empty_headings_all_dropped() {
312        let replay = Replay::new(alloc::vec![
313            Event::StartHeading { id: None, level: 1 },
314            Event::EndHeading,
315            Event::StartHeading { id: None, level: 2 },
316            Event::EndHeading,
317            Event::StartHeading { id: None, level: 3 },
318            Event::EndHeading,
319        ]);
320        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
321    }
322
323    #[test]
324    fn empty_then_nonempty_heading() {
325        let replay = Replay::new(alloc::vec![
326            Event::StartHeading { id: None, level: 1 },
327            Event::EndHeading,
328            Event::StartHeading { id: None, level: 2 },
329            Event::Text {
330                content: "x".into()
331            },
332            Event::EndHeading,
333        ]);
334        assert_eq!(
335            drain(SkipEmptyBlocks::new(replay)),
336            alloc::vec![
337                Event::StartHeading { id: None, level: 2 },
338                Event::Text {
339                    content: "x".into()
340                },
341                Event::EndHeading,
342            ]
343        );
344    }
345
346    #[test]
347    fn empty_heading_with_id_is_still_dropped() {
348        // Proves the `{ .. }` wildcard in is_skippable_start ignores all fields.
349        let replay = Replay::new(alloc::vec![
350            Event::StartHeading {
351                id: Some("anchor".into()),
352                level: 1
353            },
354            Event::EndHeading,
355        ]);
356        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
357    }
358
359    #[test]
360    fn empty_paragraph_with_alignment_is_still_dropped() {
361        // Proves alignment field is ignored by the wildcard pattern.
362        let replay = Replay::new(alloc::vec![
363            Event::StartParagraph {
364                alignment: Some(crate::TextAlignment::Center),
365                id: None
366            },
367            Event::EndParagraph,
368        ]);
369        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
370    }
371
372    #[test]
373    fn nested_empty_blockquote_containing_empty_paragraph_preserves_outer() {
374        // Documents the no-cascading limitation: the inner empty paragraph is dropped,
375        // but the outer block quote (now empty) is preserved. Lookback-1 is the contract.
376        let replay = Replay::new(alloc::vec![
377            Event::StartBlockQuote { id: None },
378            Event::StartParagraph {
379                alignment: None,
380                id: None
381            },
382            Event::EndParagraph,
383            Event::EndBlockQuote,
384        ]);
385        assert_eq!(
386            drain(SkipEmptyBlocks::new(replay)),
387            alloc::vec![Event::StartBlockQuote { id: None }, Event::EndBlockQuote,]
388        );
389    }
390
391    #[test]
392    fn non_skippable_kinds_pass_through_unchanged() {
393        let input = alloc::vec![Event::StartTable { id: None }, Event::EndTable,];
394        let replay = Replay::new(input.clone());
395        assert_eq!(drain(SkipEmptyBlocks::new(replay)), input);
396    }
397
398    #[test]
399    fn empty_document_passes_through() {
400        // StartDocument and EndDocument are NOT in the skip set.
401        let input = alloc::vec![
402            Event::StartDocument {
403                id: None,
404                language: None,
405                metadata: None
406            },
407            Event::EndDocument,
408        ];
409        let replay = Replay::new(input.clone());
410        assert_eq!(drain(SkipEmptyBlocks::new(replay)), input);
411    }
412
413    #[test]
414    fn error_from_inner_propagates_when_no_buffer() {
415        // When there is no buffered Start*, an Err from the inner source propagates directly.
416        let replay = Replay::with_terminal_error(alloc::vec![]);
417        let mut filter = SkipEmptyBlocks::new(replay);
418        assert!(filter.next_event().is_err());
419    }
420
421    #[test]
422    fn error_while_start_buffered_surfaces_immediately() {
423        // Fail-fast contract (MANIFESTO.md): when the inner source returns Err
424        // while a Start* is buffered, the error propagates immediately and the
425        // buffered Start* is dropped silently. The stream is considered terminated.
426        let replay =
427            Replay::with_terminal_error(alloc::vec![Event::StartHeading { id: None, level: 1 },]);
428        let mut filter = SkipEmptyBlocks::new(replay);
429        // First call: buffers StartHeading, calls inner (gets Err), propagates Err.
430        assert!(filter.next_event().is_err());
431        // Second call: no buffer, no pending, inner also returns Ok(None).
432        assert_eq!(filter.next_event().unwrap(), None);
433    }
434
435    #[test]
436    fn send_sync_compile_assertion() {
437        fn assert_send_sync<T>()
438        where
439            T: Send + Sync,
440        {
441        }
442        assert_send_sync::<SkipEmptyBlocks<Replay>>();
443    }
444
445    #[test]
446    fn many_consecutive_empty_blocks_do_not_blow_stack() {
447        // Stack-safety regression: the previous recursive implementation grew the call
448        // stack by ~2 frames per empty block, so a long run could overflow Rust's default
449        // 8 MiB main-thread / 2 MiB test-thread stack. The iterative `loop` form must
450        // drain an arbitrarily long run in O(1) stack. 100_000 empties is well above the
451        // overflow threshold of the old code and still completes in milliseconds.
452        const N: usize = 100_000;
453        let mut events = alloc::vec::Vec::with_capacity(N * 2);
454        for _ in 0..N {
455            events.push(Event::StartHeading { id: None, level: 1 });
456            events.push(Event::EndHeading);
457        }
458        let replay = Replay::new(events);
459        assert_eq!(drain(SkipEmptyBlocks::new(replay)), alloc::vec![]);
460    }
461}