asciidoc_parser/blocks/find.rs
1//! A search API for locating blocks within a parsed document.
2//!
3//! The public entry point is the [`FindBlocks`] trait; see its documentation
4//! for an overview and examples.
5
6use std::{borrow::Cow, slice::Iter};
7
8use crate::{
9 Document,
10 blocks::{Block, IsBlock, TableCellContent},
11};
12
13/// A search over a [`Document`] or a [`Block`] for its descendant blocks.
14///
15/// This is the Rust-native counterpart to Asciidoctor's [block-finding API].
16/// Where Asciidoctor exposes `find_by(selector = {}, &block_filter)`, this
17/// trait leans on Rust's iterator patterns: [`descendant_blocks`] is a plain
18/// [`Iterator`] you compose with the standard combinators, [`find_blocks`]
19/// takes a declarative [`BlockSelector`], and [`traverse_blocks`] gives
20/// per-block control over the walk (including subtree pruning) via the
21/// [`Descend`] enum.
22///
23/// The trait is implemented for [`Document`] and [`Block`] and is sealed (it
24/// cannot be implemented for other types); bring it into scope to call its
25/// methods. All of the returned iterators yield `&Block` in document order and
26/// never yield the receiver itself.
27///
28/// # Traversal model
29///
30/// The walk is depth-first and yields blocks in **document order**. It reaches
31/// every block reachable through the tree, including the children of
32/// Markdown-style blockquotes (whose children borrow the block's own owned
33/// source rather than the document source). AsciiDoc table cells are separate
34/// nested documents and are **not** entered by default; opt in with
35/// [`BlockSelector::traverse_documents`] (the analog of Asciidoctor's
36/// `traverse_documents` selector key).
37///
38/// # Differences from Asciidoctor
39///
40/// * **The receiver is never yielded.** These iterators visit descendants only.
41/// (Asciidoctor includes the receiver as a candidate.) A [`Document`] is not
42/// a [`Block`], so "descendants only" is the one rule that reads the same on
43/// both receivers. To include a starting block, chain it yourself:
44/// `std::iter::once(block).chain(block.descendant_blocks())`.
45/// * **`traverse_documents` is off by default** (as in Asciidoctor).
46///
47/// # Examples
48///
49/// ```
50/// use asciidoc_parser::{
51/// Parser,
52/// blocks::{Block, BlockSelector, Descend, FindBlocks, IsBlock},
53/// };
54///
55/// let doc =
56/// Parser::default().parse("= Title\n\n== First\n\n[source,rust]\n----\nfn main() {}\n----\n");
57///
58/// // The Rust-native core: a plain iterator you compose with std combinators.
59/// let sections = doc
60/// .descendant_blocks()
61/// .filter(|b| matches!(b, Block::Section(_)))
62/// .count();
63/// assert_eq!(sections, 1);
64///
65/// // A declarative selector, like `find_by(context: :listing, style: 'source')`.
66/// let listings: Vec<_> = doc
67/// .find_blocks(&BlockSelector::new().context("listing").style("source"))
68/// .collect();
69/// assert_eq!(listings.len(), 1);
70///
71/// // Per-block traversal control, like the Asciidoctor block filter. Collect
72/// // only top-level sidebars: include each sidebar but do not descend into it,
73/// // and reject everything else (so nested sidebars are not reported).
74/// let top_sidebars: Vec<_> = doc
75/// .traverse_blocks(|b| {
76/// if b.resolved_context().as_ref() == "sidebar" {
77/// Descend::Prune
78/// } else {
79/// Descend::Reject
80/// }
81/// })
82/// .collect();
83/// assert!(top_sidebars.is_empty());
84/// ```
85///
86/// [block-finding API]: https://docs.asciidoctor.org/asciidoctor/latest/api/find-blocks/
87/// [`descendant_blocks`]: Self::descendant_blocks
88/// [`find_blocks`]: Self::find_blocks
89/// [`traverse_blocks`]: Self::traverse_blocks
90pub trait FindBlocks<'a>: sealed::Sealed<'a> {
91 /// Returns a document-order iterator over this node's **direct** child
92 /// blocks (one level deep; it does not recurse).
93 ///
94 /// This is the complete direct-children accessor: it reaches the children
95 /// of a Markdown-style blockquote, which borrow the block's own owned
96 /// source. AsciiDoc table cells are separate nested documents and are not
97 /// entered here; use [`find_blocks`] with
98 /// [`BlockSelector::traverse_documents`] to reach them.
99 ///
100 /// For the full subtree rather than the immediate children, use
101 /// [`descendant_blocks`].
102 ///
103 /// [`find_blocks`]: Self::find_blocks
104 /// [`descendant_blocks`]: Self::descendant_blocks
105 fn child_blocks(&'a self) -> ChildBlocks<'a> {
106 ChildBlocks(self.seed_children(false))
107 }
108
109 /// Returns a depth-first, document-order iterator over every descendant
110 /// block.
111 ///
112 /// This is the equivalent of Asciidoctor's `find_by` with no arguments.
113 /// Because it is an ordinary [`Iterator`], the idiomatic way to search is
114 /// to compose it with the standard combinators (`filter`, `find`,
115 /// `map`, …).
116 ///
117 /// AsciiDoc table cells are not entered; use [`find_blocks`] with
118 /// [`BlockSelector::traverse_documents`] to include them.
119 ///
120 /// [`find_blocks`]: Self::find_blocks
121 fn descendant_blocks(&'a self) -> Descendants<'a> {
122 Descendants {
123 stack: vec![self.seed_children(false)],
124 traverse_documents: false,
125 }
126 }
127
128 /// Returns an iterator over the descendant blocks that match `selector`.
129 ///
130 /// This mirrors Asciidoctor's `find_by(selector)`: a block is yielded when
131 /// it matches every field the selector sets (see [`BlockSelector`]).
132 /// Traversal still descends through non-matching blocks, so matches at
133 /// any depth are found.
134 fn find_blocks(&'a self, selector: &BlockSelector<'a>) -> FindBlocksIter<'a> {
135 FindBlocksIter {
136 inner: Descendants {
137 stack: vec![self.seed_children(selector.traverse_documents)],
138 traverse_documents: selector.traverse_documents,
139 },
140 selector: selector.clone(),
141 }
142 }
143
144 /// Returns the first descendant block whose [id](IsBlock::id) equals `id`,
145 /// if any.
146 ///
147 /// Block ids are unique within a document, so at most one block can match.
148 /// This is the equivalent of Asciidoctor's `find_by(id: '…').first`.
149 fn find_block_by_id(&'a self, id: &str) -> Option<&'a Block<'a>> {
150 self.descendant_blocks()
151 .find(|block| block.id() == Some(id))
152 }
153
154 /// Returns an iterator that walks the descendant blocks under the control
155 /// of `control`, which is called once per block, in document order, to
156 /// decide whether the block is yielded and whether its children are
157 /// visited.
158 ///
159 /// This is the equivalent of Asciidoctor's `find_by` block filter; the
160 /// [`Descend`] return value plays the role of the filter's `:accept` /
161 /// `:skip` / `:reject` / `:prune` symbols and is the mechanism for pruning
162 /// whole subtrees. AsciiDoc table cells are not entered.
163 fn traverse_blocks<F>(&'a self, control: F) -> TraverseBlocks<'a, F>
164 where
165 F: FnMut(&Block<'a>) -> Descend,
166 {
167 TraverseBlocks {
168 stack: vec![self.seed_children(false)],
169 control,
170 }
171 }
172}
173
174impl<'a> FindBlocks<'a> for Document<'a> {}
175impl<'a> FindBlocks<'a> for Block<'a> {}
176
177mod sealed {
178 use super::{ChildBlocksInner, children_of};
179 use crate::Document;
180
181 /// Seals [`FindBlocks`](super::FindBlocks) and supplies the traversal seed:
182 /// the receiver's direct child blocks.
183 pub trait Sealed<'a> {
184 fn seed_children(&'a self, traverse_documents: bool) -> ChildBlocksInner<'a>;
185 }
186
187 impl<'a> Sealed<'a> for Document<'a> {
188 fn seed_children(&'a self, _traverse_documents: bool) -> ChildBlocksInner<'a> {
189 // A document's direct children are never table cells, so the flag
190 // does not affect the seed; any tables among the children are
191 // expanded with the flag during the walk itself.
192 ChildBlocksInner::Slice(self.top_level_blocks().iter())
193 }
194 }
195
196 impl<'a> Sealed<'a> for super::Block<'a> {
197 fn seed_children(&'a self, traverse_documents: bool) -> ChildBlocksInner<'a> {
198 children_of(self, traverse_documents)
199 }
200 }
201}
202
203/// A declarative selector for [`FindBlocks::find_blocks`], mirroring the
204/// selector hash of Asciidoctor's `find_by`.
205///
206/// Build one with [`new`](Self::new) and the builder methods. A field that is
207/// left unset matches any block; when several fields are set they are combined
208/// with logical AND.
209///
210/// | Method | Matches against |
211/// |---|---|
212/// | [`context`](Self::context) | [`IsBlock::resolved_context`] |
213/// | [`style`](Self::style) | [`IsBlock::declared_style`] (see below) |
214/// | [`id`](Self::id) | [`IsBlock::id`] |
215/// | [`role`](Self::role) | membership in [`IsBlock::roles`] |
216///
217/// [`style`](Self::style) matches [`declared_style`](IsBlock::declared_style),
218/// which is the block's resolved style and tracks Asciidoctor's `style` in the
219/// common cases – including variant blocks that report their style even in
220/// shorthand form (e.g. an admonition written `NOTE:` matches `style("NOTE")`).
221/// The one divergence: a style that masquerades as a built-in context (e.g.
222/// `[example]`, `[sidebar]`) is promoted to the block's context, so match those
223/// with [`context`](Self::context) rather than `style`.
224///
225/// ```
226/// use asciidoc_parser::{
227/// Parser,
228/// blocks::{BlockSelector, FindBlocks},
229/// };
230///
231/// let doc = Parser::default().parse("[#intro]\nHello.\n");
232/// let block = doc.find_blocks(&BlockSelector::new().id("intro")).next();
233/// assert!(block.is_some());
234/// ```
235#[derive(Clone, Debug, Default)]
236pub struct BlockSelector<'a> {
237 context: Option<Cow<'a, str>>,
238 style: Option<Cow<'a, str>>,
239 id: Option<Cow<'a, str>>,
240 role: Option<Cow<'a, str>>,
241 traverse_documents: bool,
242}
243
244impl<'a> BlockSelector<'a> {
245 /// Creates a selector that matches every block.
246 pub fn new() -> Self {
247 Self::default()
248 }
249
250 /// Restricts the match to blocks whose
251 /// [resolved context](IsBlock::resolved_context) equals `context` (e.g.
252 /// `"listing"`, `"section"`, `"sidebar"`).
253 pub fn context(mut self, context: impl Into<Cow<'a, str>>) -> Self {
254 self.context = Some(context.into());
255 self
256 }
257
258 /// Restricts the match to blocks whose
259 /// [declared style](IsBlock::declared_style) equals `style` (e.g.
260 /// `"source"`, `"verse"`, `"NOTE"`).
261 pub fn style(mut self, style: impl Into<Cow<'a, str>>) -> Self {
262 self.style = Some(style.into());
263 self
264 }
265
266 /// Restricts the match to the block whose [id](IsBlock::id) equals `id`.
267 pub fn id(mut self, id: impl Into<Cow<'a, str>>) -> Self {
268 self.id = Some(id.into());
269 self
270 }
271
272 /// Restricts the match to blocks that carry `role` among their
273 /// [roles](IsBlock::roles).
274 pub fn role(mut self, role: impl Into<Cow<'a, str>>) -> Self {
275 self.role = Some(role.into());
276 self
277 }
278
279 /// Sets whether the traversal descends into AsciiDoc table cells (nested
280 /// documents). Off by default.
281 pub fn traverse_documents(mut self, traverse_documents: bool) -> Self {
282 self.traverse_documents = traverse_documents;
283 self
284 }
285
286 /// Returns `true` if `block` satisfies every field this selector sets.
287 ///
288 /// This does not consider [`traverse_documents`](Self::traverse_documents),
289 /// which governs traversal rather than whether an individual block matches.
290 pub fn matches(&self, block: &Block<'_>) -> bool {
291 if let Some(context) = &self.context
292 && block.resolved_context().as_ref() != context.as_ref()
293 {
294 return false;
295 }
296
297 if let Some(style) = &self.style
298 && block.declared_style() != Some(style.as_ref())
299 {
300 return false;
301 }
302
303 if let Some(id) = &self.id
304 && block.id() != Some(id.as_ref())
305 {
306 return false;
307 }
308
309 if let Some(role) = &self.role
310 && !block.roles().iter().any(|r| *r == role.as_ref())
311 {
312 return false;
313 }
314
315 true
316 }
317}
318
319/// The disposition of a block during a [`traverse_blocks`] walk: whether the
320/// block is included in the results and whether its children are visited.
321///
322/// The variants correspond one-to-one with the return values of Asciidoctor's
323/// `find_by` block filter.
324///
325/// [`traverse_blocks`]: FindBlocks::traverse_blocks
326#[derive(Clone, Copy, Debug, Eq, PartialEq)]
327pub enum Descend {
328 /// Include this block and descend into its children. (Asciidoctor `:accept`
329 /// / `true`.)
330 Accept,
331
332 /// Omit this block but still descend into its children. (Asciidoctor
333 /// `:skip` / `false`.)
334 Skip,
335
336 /// Omit this block and skip its children. (Asciidoctor `:reject`.)
337 Reject,
338
339 /// Include this block but skip its children. (Asciidoctor `:prune`.)
340 Prune,
341}
342
343/// A depth-first, document-order iterator over descendant blocks, returned by
344/// [`FindBlocks::descendant_blocks`].
345pub struct Descendants<'a> {
346 stack: Vec<ChildBlocksInner<'a>>,
347 traverse_documents: bool,
348}
349
350impl<'a> Iterator for Descendants<'a> {
351 type Item = &'a Block<'a>;
352
353 fn next(&mut self) -> Option<Self::Item> {
354 loop {
355 match self.stack.last_mut()?.next() {
356 None => {
357 self.stack.pop();
358 }
359 Some(block) => {
360 self.stack.push(children_of(block, self.traverse_documents));
361 return Some(block);
362 }
363 }
364 }
365 }
366}
367
368/// An iterator over the descendant blocks matching a [`BlockSelector`],
369/// returned by [`FindBlocks::find_blocks`].
370pub struct FindBlocksIter<'a> {
371 inner: Descendants<'a>,
372 selector: BlockSelector<'a>,
373}
374
375impl<'a> Iterator for FindBlocksIter<'a> {
376 type Item = &'a Block<'a>;
377
378 fn next(&mut self) -> Option<Self::Item> {
379 self.inner
380 .by_ref()
381 .find(|block| self.selector.matches(block))
382 }
383}
384
385/// An iterator that walks descendant blocks under the control of a closure,
386/// returned by [`FindBlocks::traverse_blocks`].
387pub struct TraverseBlocks<'a, F> {
388 stack: Vec<ChildBlocksInner<'a>>,
389 control: F,
390}
391
392impl<'a, F> Iterator for TraverseBlocks<'a, F>
393where
394 F: FnMut(&Block<'a>) -> Descend,
395{
396 type Item = &'a Block<'a>;
397
398 fn next(&mut self) -> Option<Self::Item> {
399 loop {
400 let block = match self.stack.last_mut()?.next() {
401 None => {
402 self.stack.pop();
403 continue;
404 }
405 Some(block) => block,
406 };
407
408 match (self.control)(block) {
409 Descend::Accept => {
410 self.stack.push(children_of(block, false));
411 return Some(block);
412 }
413 Descend::Skip => {
414 self.stack.push(children_of(block, false));
415 }
416 Descend::Prune => return Some(block),
417 Descend::Reject => {}
418 }
419 }
420 }
421}
422
423/// A document-order iterator over the direct child blocks of a single node.
424///
425/// This is the return type of [`FindBlocks::child_blocks`] and of each block
426/// type's inherent `child_blocks()` accessor. It yields `&Block` for the
427/// receiver's immediate children only (it does not recurse – use
428/// [`FindBlocks::descendant_blocks`] for the full subtree). Unlike a raw
429/// structural walk, it reaches the children of a Markdown-style blockquote
430/// (which borrow the block's own owned source); AsciiDoc table cells are
431/// separate nested documents and are not entered.
432///
433/// The concrete container behind this iterator is deliberately hidden so it can
434/// change without breaking callers.
435pub struct ChildBlocks<'a>(ChildBlocksInner<'a>);
436
437impl<'a> ChildBlocks<'a> {
438 /// Builds a child-block iterator over a contiguous slice of blocks.
439 pub(crate) fn from_slice(blocks: &'a [Block<'a>]) -> Self {
440 ChildBlocks(ChildBlocksInner::Slice(blocks.iter()))
441 }
442
443 /// Builds an empty child-block iterator, for block types that never have
444 /// children.
445 pub(crate) fn empty() -> Self {
446 ChildBlocks(ChildBlocksInner::Empty)
447 }
448}
449
450impl<'a> Iterator for ChildBlocks<'a> {
451 type Item = &'a Block<'a>;
452
453 fn next(&mut self) -> Option<Self::Item> {
454 self.0.next()
455 }
456}
457
458/// An iterator over the direct child blocks of a single node.
459///
460/// This is the traversal frame the depth-first walkers push and pop, and the
461/// value wrapped by the public [`ChildBlocks`]. It is not part of the public
462/// API surface: it appears only in the signature of the sealed `Sealed` trait
463/// (hence `pub` to satisfy the privacy lint), and the enclosing `find` module
464/// is private, so it cannot be named from outside this crate.
465#[doc(hidden)]
466pub enum ChildBlocksInner<'a> {
467 /// Children stored contiguously in a slice (the common case).
468 Slice(Iter<'a, Block<'a>>),
469
470 /// Children gathered across a table's AsciiDoc cells, boxed because the
471 /// flattened iterator type cannot be named. Allocated only when actually
472 /// descending into a table with `traverse_documents` enabled.
473 Boxed(Box<dyn Iterator<Item = &'a Block<'a>> + 'a>),
474
475 /// No children.
476 Empty,
477}
478
479impl<'a> Iterator for ChildBlocksInner<'a> {
480 type Item = &'a Block<'a>;
481
482 fn next(&mut self) -> Option<Self::Item> {
483 match self {
484 ChildBlocksInner::Slice(iter) => iter.next(),
485 ChildBlocksInner::Boxed(iter) => iter.next(),
486 ChildBlocksInner::Empty => None,
487 }
488 }
489}
490
491/// Returns the direct child blocks of `block`, in document order.
492///
493/// This is the one place that knows how to reach the children the plain
494/// per-type accessors cannot, or that depend on `traverse_documents`:
495///
496/// * An AsciiDoc table cell is a nested document; its blocks are reached only
497/// when `traverse_documents` is set. A table's own inherent
498/// [`child_blocks`](crate::blocks::TableBlock::child_blocks) reports no
499/// children, so the cell walk lives here.
500/// * Every other block type defers to its inherent
501/// [`child_blocks`](FindBlocks::child_blocks)-style accessor, which already
502/// handles the Markdown-blockquote case (whose children borrow the block's
503/// own owned source and are read through [`QuoteBlock::blocks`]).
504///
505/// [`QuoteBlock::blocks`]: crate::blocks::QuoteBlock::blocks
506fn children_of<'a>(block: &'a Block<'a>, traverse_documents: bool) -> ChildBlocksInner<'a> {
507 match block {
508 Block::Table(table) => {
509 if traverse_documents {
510 let blocks = table
511 .header_row()
512 .into_iter()
513 .chain(table.body_rows().iter())
514 .chain(table.footer_row())
515 .flat_map(|row| row.cells().iter())
516 .filter_map(|cell| match cell.content() {
517 TableCellContent::AsciiDoc(cell) => Some(cell.blocks().iter()),
518 TableCellContent::Simple(_) => None,
519 })
520 .flatten();
521
522 ChildBlocksInner::Boxed(Box::new(blocks))
523 } else {
524 ChildBlocksInner::Empty
525 }
526 }
527
528 Block::Quote(quote) => quote.child_blocks().0,
529 Block::Admonition(admonition) => admonition.child_blocks().0,
530 Block::Section(section) => section.child_blocks().0,
531 Block::List(list) => list.child_blocks().0,
532 Block::ListItem(list_item) => list_item.child_blocks().0,
533 Block::Preamble(preamble) => preamble.child_blocks().0,
534 Block::CompoundDelimited(compound) => compound.child_blocks().0,
535
536 Block::Simple(_)
537 | Block::Media(_)
538 | Block::RawDelimited(_)
539 | Block::Break(_)
540 | Block::Toc(_)
541 | Block::DocumentAttribute(_) => ChildBlocksInner::Empty,
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 #![allow(clippy::unwrap_used)]
548
549 use crate::{
550 blocks::{Block, BlockSelector, Descend, FindBlocks, IsBlock},
551 tests::prelude::*,
552 };
553
554 /// Collects the resolved context of every descendant block, in order.
555 fn contexts<'a, T: FindBlocks<'a>>(node: &'a T) -> Vec<String> {
556 node.descendant_blocks()
557 .map(|b| b.resolved_context().as_ref().to_string())
558 .collect()
559 }
560
561 #[test]
562 fn empty_document_has_no_descendants() {
563 let doc = Parser::default().parse("");
564 assert_eq!(doc.descendant_blocks().count(), 0);
565 assert!(doc.find_block_by_id("anything").is_none());
566 }
567
568 #[test]
569 fn document_order_and_nesting() {
570 // A section containing a list (which owns its items) and a source
571 // listing. The walk should yield each in document order, descending into
572 // the section and the list.
573 let doc = Parser::default()
574 .parse("== Section\n\n* one\n* two\n\n[source,rust]\n----\nfn main() {}\n----\n");
575
576 let contexts = contexts(&doc);
577
578 // Section, then the list, then each item and the paragraph it holds,
579 // then the listing.
580 assert_eq!(
581 contexts,
582 vec![
583 "section",
584 "list",
585 "list_item",
586 "paragraph",
587 "list_item",
588 "paragraph",
589 "listing"
590 ]
591 );
592 }
593
594 #[test]
595 fn descendant_blocks_compose_with_std_combinators() {
596 let doc = Parser::default().parse("== A\n\ntext\n\n== B\n\ntext\n");
597
598 let sections = doc
599 .descendant_blocks()
600 .filter(|b| matches!(b, Block::Section(_)))
601 .count();
602
603 assert_eq!(sections, 2);
604 }
605
606 #[test]
607 fn find_blocks_by_context_and_style() {
608 let doc = Parser::default()
609 .parse("[source,rust]\n----\nfn main() {}\n----\n\n----\nplain listing\n----\n");
610
611 // Both are listings.
612 assert_eq!(
613 doc.find_blocks(&BlockSelector::new().context("listing"))
614 .count(),
615 2
616 );
617
618 // Only one is a source listing.
619 let sources: Vec<_> = doc
620 .find_blocks(&BlockSelector::new().context("listing").style("source"))
621 .collect();
622 assert_eq!(sources.len(), 1);
623 assert_eq!(sources.first().unwrap().declared_style(), Some("source"));
624 }
625
626 #[test]
627 fn find_blocks_by_context_excludes_non_matching() {
628 // The paragraph is visited but excluded by the context filter, leaving
629 // only the listing.
630 let doc = Parser::default().parse("A paragraph.\n\n----\na listing\n----\n");
631
632 let listings: Vec<_> = doc
633 .find_blocks(&BlockSelector::new().context("listing"))
634 .collect();
635
636 assert_eq!(listings.len(), 1);
637 assert_eq!(
638 listings.first().unwrap().resolved_context().as_ref(),
639 "listing"
640 );
641 }
642
643 #[test]
644 fn find_blocks_by_id_selector() {
645 // Exercises the `id` selector field; the walk visits the non-matching
646 // "World." paragraph as well as the matching block.
647 let doc = Parser::default().parse("[#intro]\nHello.\n\nWorld.\n");
648
649 let matched: Vec<_> = doc.find_blocks(&BlockSelector::new().id("intro")).collect();
650
651 assert_eq!(matched.len(), 1);
652 assert_eq!(matched.first().unwrap().id(), Some("intro"));
653 }
654
655 #[test]
656 fn find_blocks_by_role() {
657 let doc = Parser::default().parse("[.important]\nAttention.\n\nOrdinary.\n");
658
659 let matched: Vec<_> = doc
660 .find_blocks(&BlockSelector::new().role("important"))
661 .collect();
662
663 assert_eq!(matched.len(), 1);
664 assert!(matched.first().unwrap().roles().contains(&"important"));
665 }
666
667 #[test]
668 fn find_block_by_id_hit_and_miss() {
669 let doc = Parser::default().parse("[#intro]\nHello.\n\nWorld.\n");
670
671 let intro = doc.find_block_by_id("intro").unwrap();
672 assert_eq!(intro.id(), Some("intro"));
673 assert!(doc.find_block_by_id("missing").is_none());
674 }
675
676 #[test]
677 fn traverse_blocks_accept_yields_everything() {
678 let doc = Parser::default().parse("== S\n\n* one\n* two\n");
679
680 let all: Vec<_> = doc.traverse_blocks(|_| Descend::Accept).collect();
681 let plain: Vec<_> = doc.descendant_blocks().collect();
682
683 assert_eq!(all, plain);
684 }
685
686 #[test]
687 fn traverse_blocks_prune_stops_at_matched_subtree() {
688 // A sidebar nested inside a sidebar, followed by a top-level paragraph.
689 // Prune on the outer sidebar collects it but does not descend (so the
690 // inner sidebar is not reported); the trailing paragraph is rejected.
691 let doc = Parser::default()
692 .parse("****\nOuter.\n\n[.inner]\n*****\nInner.\n*****\n****\n\nAfter.\n");
693
694 let sidebars: Vec<_> = doc
695 .traverse_blocks(|b| {
696 if b.resolved_context().as_ref() == "sidebar" {
697 Descend::Prune
698 } else {
699 Descend::Reject
700 }
701 })
702 .collect();
703
704 // Only the outer sidebar: the inner sidebar is behind the prune, and the
705 // trailing paragraph is rejected.
706 assert_eq!(sidebars.len(), 1);
707 }
708
709 #[test]
710 fn traverse_blocks_reject_excludes_block_and_children() {
711 // The counterpart to the skip case: rejecting the sidebar excludes it
712 // *and* the paragraph inside it, while the top-level sibling paragraph
713 // is still accepted.
714 let doc = Parser::default().parse("****\nInside.\n****\n\nOutside.\n");
715
716 let contexts: Vec<_> = doc
717 .traverse_blocks(|b| {
718 if b.resolved_context().as_ref() == "sidebar" {
719 Descend::Reject
720 } else {
721 Descend::Accept
722 }
723 })
724 .map(|b| b.resolved_context().as_ref().to_string())
725 .collect();
726
727 assert_eq!(contexts, vec!["paragraph"]);
728 }
729
730 #[test]
731 fn traverse_blocks_skip_excludes_block_but_visits_children() {
732 let doc = Parser::default().parse("****\nInside sidebar.\n****\n");
733
734 // Skip the sidebar itself but descend into it: we should see the inner
735 // paragraph but not the sidebar.
736 let contexts: Vec<_> = doc
737 .traverse_blocks(|b| {
738 if b.resolved_context().as_ref() == "sidebar" {
739 Descend::Skip
740 } else {
741 Descend::Accept
742 }
743 })
744 .map(|b| b.resolved_context().as_ref().to_string())
745 .collect();
746
747 assert_eq!(contexts, vec!["paragraph"]);
748 }
749
750 #[test]
751 fn markdown_quote_children_are_reached() {
752 // Regression: a Markdown-style blockquote's children borrow the block's
753 // own owned source rather than the document source, but the search API
754 // must still reach them (via `QuoteBlock::blocks()`).
755 let doc = Parser::default().parse("> A quoted paragraph.\n");
756
757 let contexts = contexts(&doc);
758 assert_eq!(contexts, vec!["quote", "paragraph"]);
759 }
760
761 #[test]
762 fn table_cells_require_traverse_documents() {
763 // An AsciiDoc (`a|`) cell whose content is its own nested document.
764 let doc = Parser::default().parse("|===\na| Cell _text_.\n|===\n");
765
766 // By default the walk stops at the table.
767 assert_eq!(doc.find_blocks(&BlockSelector::new()).count(), 1);
768 assert_eq!(
769 doc.find_blocks(&BlockSelector::new().context("table"))
770 .count(),
771 1
772 );
773
774 // Opting in reaches the paragraph inside the cell.
775 assert_eq!(
776 doc.find_blocks(&BlockSelector::new().traverse_documents(true))
777 .count(),
778 2
779 );
780 }
781
782 #[test]
783 fn traverse_documents_skips_non_asciidoc_cells() {
784 // One plain cell and one AsciiDoc (`a|`) cell. With `traverse_documents`
785 // the plain cell contributes no blocks (its content is inline), while the
786 // AsciiDoc cell contributes its paragraph.
787 let doc = Parser::default().parse("|===\n| plain\na| AsciiDoc _text_.\n|===\n");
788
789 let deep = doc
790 .find_blocks(&BlockSelector::new().traverse_documents(true))
791 .count();
792
793 // The table plus the one paragraph from the AsciiDoc cell.
794 assert_eq!(deep, 2);
795 }
796
797 #[test]
798 fn find_blocks_on_a_block_searches_its_subtree() {
799 // The API is available on `Block`, not only `Document`.
800 let doc = Parser::default().parse("== Section\n\n* one\n* two\n");
801
802 let section = doc
803 .descendant_blocks()
804 .find(|b| matches!(b, Block::Section(_)))
805 .unwrap();
806
807 // The section's own descendants: the list, and each item with the
808 // paragraph it holds.
809 assert_eq!(
810 contexts(section),
811 vec!["list", "list_item", "paragraph", "list_item", "paragraph"]
812 );
813 }
814
815 #[test]
816 fn child_blocks_yields_direct_children_only() {
817 // `child_blocks()` is one level deep: the document's only direct child
818 // is the section, not the list and paragraphs nested inside it.
819 let doc = Parser::default().parse("== Section\n\n* one\n* two\n");
820
821 let child_contexts: Vec<_> = doc
822 .child_blocks()
823 .map(|b| b.resolved_context().as_ref().to_string())
824 .collect();
825
826 assert_eq!(child_contexts, vec!["section"]);
827
828 // On a `Block`, `child_blocks()` returns that block's own direct
829 // children (the section's list), still without recursing.
830 let section = doc.child_blocks().next().unwrap();
831 let section_child_contexts: Vec<_> = section
832 .child_blocks()
833 .map(|b| b.resolved_context().as_ref().to_string())
834 .collect();
835
836 assert_eq!(section_child_contexts, vec!["list"]);
837 }
838
839 #[test]
840 fn child_blocks_reaches_markdown_quote_children() {
841 // Regression for #894: a Markdown-style blockquote's children are not
842 // exposed through a bare structural walk, but `child_blocks()` reaches
843 // them.
844 let doc = Parser::default().parse("> A quoted paragraph.\n");
845
846 let quote = doc.child_blocks().next().unwrap();
847 assert_eq!(quote.resolved_context().as_ref(), "quote");
848
849 let quote_child_contexts: Vec<_> = quote
850 .child_blocks()
851 .map(|b| b.resolved_context().as_ref().to_string())
852 .collect();
853
854 assert_eq!(quote_child_contexts, vec!["paragraph"]);
855 }
856
857 #[test]
858 fn leaf_block_types_report_no_child_blocks() {
859 // Every leaf block type answers its inherent `child_blocks()` with an
860 // empty iterator.
861 // The trailing section (a non-leaf block) exercises the `_` arm below.
862 let doc = Parser::default().parse(
863 "A paragraph.\n\nimage::sunset.jpg[]\n\n----\nlisting\n----\n\n|===\n|cell\n|===\n\n'''\n\n== Section\n\nInside.\n",
864 );
865
866 let mut saw_simple = false;
867 let mut saw_media = false;
868 let mut saw_raw = false;
869 let mut saw_table = false;
870 let mut saw_break = false;
871
872 for block in doc.descendant_blocks() {
873 match block {
874 Block::Simple(b) => {
875 assert_eq!(b.child_blocks().count(), 0);
876 saw_simple = true;
877 }
878 Block::Media(b) => {
879 assert_eq!(b.child_blocks().count(), 0);
880 saw_media = true;
881 }
882 Block::RawDelimited(b) => {
883 assert_eq!(b.child_blocks().count(), 0);
884 saw_raw = true;
885 }
886 Block::Table(b) => {
887 assert_eq!(b.child_blocks().count(), 0);
888 saw_table = true;
889 }
890 Block::Break(b) => {
891 assert_eq!(b.child_blocks().count(), 0);
892 saw_break = true;
893 }
894 _ => {}
895 }
896 }
897
898 // Ensure the fixture actually exercised each leaf type.
899 assert!(saw_simple && saw_media && saw_raw && saw_table && saw_break);
900 }
901
902 #[test]
903 fn child_blocks_does_not_enter_table_cells() {
904 // A table reports no direct child blocks; its AsciiDoc cell content is a
905 // separate nested document, reachable only through `find_blocks` with
906 // `traverse_documents`.
907 let doc = Parser::default().parse("|===\na| Cell _text_.\n|===\n");
908
909 let table = doc.child_blocks().next().unwrap();
910 assert_eq!(table.resolved_context().as_ref(), "table");
911 assert_eq!(table.child_blocks().count(), 0);
912 }
913}