Skip to main content

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