pub struct SkipEmptyBlocks<S: EventSource> { /* private fields */ }Expand description
A streaming EventSource adapter that suppresses empty Heading, BlockQuote, and
Paragraph Start/End pairs from the wrapped source.
The adapter uses a 1-event look-back (hold-1) algorithm. When it sees a candidate
skippable Start* event (StartHeading, StartBlockQuote, or StartParagraph), it
buffers that event and peeks the next event from the inner source. If the next event is
the matching End* (i.e., the block is empty), both events are dropped and the adapter
keeps draining iteratively until it finds an event to emit. If the next event is something
else, the buffered Start* is emitted immediately and the “something else” is stashed as
pending for the next call. Memory is O(1) — at most two Event values are held at any
time. Stack is O(1) — the implementation is a single loop with no recursion, so an
arbitrarily long run of empty blocks consumes constant stack regardless of input size.
§Skip Set
Exactly three Start/End pairs are matched:
Event::StartHeading { .. }↔Event::EndHeadingEvent::StartBlockQuote { .. }↔Event::EndBlockQuoteEvent::StartParagraph { .. }↔Event::EndParagraph
No other variants are suppressed. Empty StartTable, StartOrderedListItem, and all
other containers pass through unchanged.
§Asymmetric API
docspec-cli and docspec-http apply this filter automatically by default.
Library users opt in by wrapping their EventSource explicitly:
SkipEmptyBlocks::new(my_reader).
§Known Limitations
No cascading: an outer container that becomes empty because its inner contents were
filtered is preserved. Example: StartBlockQuote → StartParagraph → EndParagraph → EndBlockQuote
produces StartBlockQuote → EndBlockQuote (the inner empty paragraph pair is dropped, but the
outer block quote is preserved). A subsequent pass would be needed to suppress the outer.
Empty table cells: an empty table cell containing only StartParagraph → EndParagraph
will have the inner pair dropped, leaving the cell with no child events. The cell itself is
preserved (table cells are not in the skip set).
Fail-fast: if the inner source returns Err while a Start* is buffered, the error
propagates immediately and the buffered Start* is dropped silently. The stream is considered
terminated; no partial recovery is attempted.
§Example
use docspec_core::{Event, EventSource, Result, SkipEmptyBlocks};
struct Replay {
events: std::vec::IntoIter<Event>,
}
impl Replay {
fn new(events: Vec<Event>) -> Self {
Self { events: events.into_iter() }
}
}
impl EventSource for Replay {
fn next_event(&mut self) -> Result<Option<Event>> {
Ok(self.events.next())
}
}
// An empty heading followed by a heading with text:
let inner = Replay::new(vec![
Event::StartHeading { id: None, level: 1 },
Event::EndHeading,
Event::StartHeading { id: None, level: 2 },
Event::Text { content: String::from("Hello") },
Event::EndHeading,
]);
let mut filtered = SkipEmptyBlocks::new(inner);
// The empty H1 is dropped; the H2 with text passes through.
assert_eq!(
filtered.next_event().unwrap(),
Some(Event::StartHeading { id: None, level: 2 }),
);
assert_eq!(
filtered.next_event().unwrap(),
Some(Event::Text { content: String::from("Hello") }),
);
assert_eq!(filtered.next_event().unwrap(), Some(Event::EndHeading));
assert_eq!(filtered.next_event().unwrap(), None);