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}