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 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::resolved_style`] (see below) |
214/// | [`id`](Self::id) | [`IsBlock::id`] |
215/// | [`role`](Self::role) | membership in [`IsBlock::roles`] |
216///
217/// [`style`](Self::style) matches [`resolved_style`](IsBlock::resolved_style),
218/// which tracks Asciidoctor's `style` – including a style declared in shorthand
219/// form (e.g. an admonition written `NOTE:` matches `style("NOTE")`) and a
220/// style acquired implicitly during parsing (e.g. a list that inherited
221/// `bibliography` from its section matches `style("bibliography")`). The one
222/// divergence: a style that masquerades as a built-in context (e.g.
223/// `[example]`, `[sidebar]`) is promoted to the block's context, so match those
224/// with [`context`](Self::context) rather than `style`.
225///
226/// ```
227/// use asciidoc_parser::{
228///     Parser,
229///     blocks::{BlockSelector, FindBlocks},
230/// };
231///
232/// let doc = Parser::default().parse("[#intro]\nHello.\n");
233/// let block = doc.find_blocks(&BlockSelector::new().id("intro")).next();
234/// assert!(block.is_some());
235/// ```
236#[derive(Clone, Debug, Default)]
237pub struct BlockSelector<'a> {
238    context: Option<Cow<'a, str>>,
239    style: Option<Cow<'a, str>>,
240    id: Option<Cow<'a, str>>,
241    role: Option<Cow<'a, str>>,
242    traverse_documents: bool,
243}
244
245impl<'a> BlockSelector<'a> {
246    /// Creates a selector that matches every block.
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    /// Restricts the match to blocks whose
252    /// [resolved context](IsBlock::resolved_context) equals `context` (e.g.
253    /// `"listing"`, `"section"`, `"sidebar"`).
254    pub fn context(mut self, context: impl Into<Cow<'a, str>>) -> Self {
255        self.context = Some(context.into());
256        self
257    }
258
259    /// Restricts the match to blocks whose
260    /// [resolved style](IsBlock::resolved_style) equals `style` (e.g.
261    /// `"source"`, `"verse"`, `"NOTE"`, `"bibliography"`). Matching the
262    /// resolved style (rather than the declared one) mirrors Asciidoctor's
263    /// `find_by(style: …)`, so a list that acquired the `bibliography` style
264    /// implicitly from its section is matched by `style("bibliography")`.
265    pub fn style(mut self, style: impl Into<Cow<'a, str>>) -> Self {
266        self.style = Some(style.into());
267        self
268    }
269
270    /// Restricts the match to the block whose [id](IsBlock::id) equals `id`.
271    pub fn id(mut self, id: impl Into<Cow<'a, str>>) -> Self {
272        self.id = Some(id.into());
273        self
274    }
275
276    /// Restricts the match to blocks that carry `role` among their
277    /// [roles](IsBlock::roles).
278    pub fn role(mut self, role: impl Into<Cow<'a, str>>) -> Self {
279        self.role = Some(role.into());
280        self
281    }
282
283    /// Sets whether the traversal descends into AsciiDoc table cells (nested
284    /// documents). Off by default.
285    pub fn traverse_documents(mut self, traverse_documents: bool) -> Self {
286        self.traverse_documents = traverse_documents;
287        self
288    }
289
290    /// Returns `true` if `block` satisfies every field this selector sets.
291    ///
292    /// This does not consider [`traverse_documents`](Self::traverse_documents),
293    /// which governs traversal rather than whether an individual block matches.
294    pub fn matches(&self, block: &Block<'_>) -> bool {
295        if let Some(context) = &self.context
296            && block.resolved_context().as_ref() != context.as_ref()
297        {
298            return false;
299        }
300
301        if let Some(style) = &self.style
302            && block.resolved_style() != Some(style.as_ref())
303        {
304            return false;
305        }
306
307        if let Some(id) = &self.id
308            && block.id() != Some(id.as_ref())
309        {
310            return false;
311        }
312
313        if let Some(role) = &self.role
314            && !block.roles().iter().any(|r| *r == role.as_ref())
315        {
316            return false;
317        }
318
319        true
320    }
321}
322
323/// The disposition of a block during a [`traverse_blocks`] walk: whether the
324/// block is included in the results and whether its children are visited.
325///
326/// The variants correspond one-to-one with the return values of Asciidoctor's
327/// `find_by` block filter.
328///
329/// [`traverse_blocks`]: FindBlocks::traverse_blocks
330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
331pub enum Descend {
332    /// Include this block and descend into its children. (Asciidoctor `:accept`
333    /// / `true`.)
334    Accept,
335
336    /// Omit this block but still descend into its children. (Asciidoctor
337    /// `:skip` / `false`.)
338    Skip,
339
340    /// Omit this block and skip its children. (Asciidoctor `:reject`.)
341    Reject,
342
343    /// Include this block but skip its children. (Asciidoctor `:prune`.)
344    Prune,
345}
346
347/// A depth-first, document-order iterator over descendant blocks, returned by
348/// [`FindBlocks::descendant_blocks`].
349pub struct Descendants<'a> {
350    stack: Vec<ChildBlocksInner<'a>>,
351    traverse_documents: bool,
352}
353
354impl<'a> Iterator for Descendants<'a> {
355    type Item = &'a Block<'a>;
356
357    fn next(&mut self) -> Option<Self::Item> {
358        loop {
359            match self.stack.last_mut()?.next() {
360                None => {
361                    self.stack.pop();
362                }
363                Some(block) => {
364                    self.stack.push(children_of(block, self.traverse_documents));
365                    return Some(block);
366                }
367            }
368        }
369    }
370}
371
372/// An iterator over the descendant blocks matching a [`BlockSelector`],
373/// returned by [`FindBlocks::find_blocks`].
374pub struct FindBlocksIter<'a> {
375    inner: Descendants<'a>,
376    selector: BlockSelector<'a>,
377}
378
379impl<'a> Iterator for FindBlocksIter<'a> {
380    type Item = &'a Block<'a>;
381
382    fn next(&mut self) -> Option<Self::Item> {
383        self.inner
384            .by_ref()
385            .find(|block| self.selector.matches(block))
386    }
387}
388
389/// An iterator that walks descendant blocks under the control of a closure,
390/// returned by [`FindBlocks::traverse_blocks`].
391pub struct TraverseBlocks<'a, F> {
392    stack: Vec<ChildBlocksInner<'a>>,
393    control: F,
394}
395
396impl<'a, F> Iterator for TraverseBlocks<'a, F>
397where
398    F: FnMut(&Block<'a>) -> Descend,
399{
400    type Item = &'a Block<'a>;
401
402    fn next(&mut self) -> Option<Self::Item> {
403        loop {
404            let block = match self.stack.last_mut()?.next() {
405                None => {
406                    self.stack.pop();
407                    continue;
408                }
409                Some(block) => block,
410            };
411
412            match (self.control)(block) {
413                Descend::Accept => {
414                    self.stack.push(children_of(block, false));
415                    return Some(block);
416                }
417                Descend::Skip => {
418                    self.stack.push(children_of(block, false));
419                }
420                Descend::Prune => return Some(block),
421                Descend::Reject => {}
422            }
423        }
424    }
425}
426
427/// A document-order iterator over the direct child blocks of a single node.
428///
429/// This is the return type of [`FindBlocks::child_blocks`] and of each block
430/// type's inherent `child_blocks()` accessor. It yields `&Block` for the
431/// receiver's immediate children only (it does not recurse – use
432/// [`FindBlocks::descendant_blocks`] for the full subtree). Unlike a raw
433/// structural walk, it reaches the children of a Markdown-style blockquote
434/// (which borrow the block's own owned source); AsciiDoc table cells are
435/// separate nested documents and are not entered.
436///
437/// The concrete container behind this iterator is deliberately hidden so it can
438/// change without breaking callers.
439pub struct ChildBlocks<'a>(ChildBlocksInner<'a>);
440
441impl<'a> ChildBlocks<'a> {
442    /// Builds a child-block iterator over a contiguous slice of blocks.
443    pub(crate) fn from_slice(blocks: &'a [Block<'a>]) -> Self {
444        ChildBlocks(ChildBlocksInner::Slice(blocks.iter()))
445    }
446
447    /// Builds an empty child-block iterator, for block types that never have
448    /// children.
449    pub(crate) fn empty() -> Self {
450        ChildBlocks(ChildBlocksInner::Empty)
451    }
452}
453
454impl<'a> Iterator for ChildBlocks<'a> {
455    type Item = &'a Block<'a>;
456
457    fn next(&mut self) -> Option<Self::Item> {
458        self.0.next()
459    }
460}
461
462/// An iterator over the direct child blocks of a single node.
463///
464/// This is the traversal frame the depth-first walkers push and pop, and the
465/// value wrapped by the public [`ChildBlocks`]. It is not part of the public
466/// API surface: it appears only in the signature of the sealed `Sealed` trait
467/// (hence `pub` to satisfy the privacy lint), and the enclosing `find` module
468/// is private, so it cannot be named from outside this crate.
469#[doc(hidden)]
470pub enum ChildBlocksInner<'a> {
471    /// Children stored contiguously in a slice (the common case).
472    Slice(Iter<'a, Block<'a>>),
473
474    /// Children gathered across a table's AsciiDoc cells, boxed because the
475    /// flattened iterator type cannot be named. Allocated only when actually
476    /// descending into a table with `traverse_documents` enabled.
477    Boxed(Box<dyn Iterator<Item = &'a Block<'a>> + 'a>),
478
479    /// No children.
480    Empty,
481}
482
483impl<'a> Iterator for ChildBlocksInner<'a> {
484    type Item = &'a Block<'a>;
485
486    fn next(&mut self) -> Option<Self::Item> {
487        match self {
488            ChildBlocksInner::Slice(iter) => iter.next(),
489            ChildBlocksInner::Boxed(iter) => iter.next(),
490            ChildBlocksInner::Empty => None,
491        }
492    }
493}
494
495/// Returns the direct child blocks of `block`, in document order.
496///
497/// This is the one place that knows how to reach the children the plain
498/// per-type accessors cannot, or that depend on `traverse_documents`:
499///
500/// * An AsciiDoc table cell is a nested document; its blocks are reached only
501///   when `traverse_documents` is set. A table's own inherent
502///   [`child_blocks`](crate::blocks::TableBlock::child_blocks) reports no
503///   children, so the cell walk lives here.
504/// * Every other block type defers to its inherent
505///   [`child_blocks`](FindBlocks::child_blocks)-style accessor, which already
506///   handles the Markdown-blockquote case (whose children borrow the block's
507///   own owned source and are read through [`QuoteBlock::blocks`]).
508///
509/// [`QuoteBlock::blocks`]: crate::blocks::QuoteBlock::blocks
510fn children_of<'a>(block: &'a Block<'a>, traverse_documents: bool) -> ChildBlocksInner<'a> {
511    match block {
512        Block::Table(table) => {
513            if traverse_documents {
514                let blocks = table
515                    .header_row()
516                    .into_iter()
517                    .chain(table.body_rows().iter())
518                    .chain(table.footer_row())
519                    .flat_map(|row| row.cells().iter())
520                    .filter_map(|cell| match cell.content() {
521                        TableCellContent::AsciiDoc(cell) => Some(cell.blocks().iter()),
522                        TableCellContent::Simple(_) => None,
523                    })
524                    .flatten();
525
526                ChildBlocksInner::Boxed(Box::new(blocks))
527            } else {
528                ChildBlocksInner::Empty
529            }
530        }
531
532        Block::Quote(quote) => quote.child_blocks().0,
533        Block::Admonition(admonition) => admonition.child_blocks().0,
534        Block::Section(section) => section.child_blocks().0,
535        Block::List(list) => list.child_blocks().0,
536        Block::ListItem(list_item) => list_item.child_blocks().0,
537        Block::Preamble(preamble) => preamble.child_blocks().0,
538        Block::CompoundDelimited(compound) => compound.child_blocks().0,
539
540        Block::Simple(_)
541        | Block::Media(_)
542        | Block::RawDelimited(_)
543        | Block::Break(_)
544        | Block::Toc(_)
545        | Block::DocumentAttribute(_) => ChildBlocksInner::Empty,
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    #![allow(clippy::unwrap_used)]
552
553    use crate::{
554        blocks::{Block, BlockSelector, Descend, FindBlocks, IsBlock},
555        tests::prelude::*,
556    };
557
558    /// Collects the resolved context of every descendant block, in order.
559    fn contexts<'a, T: FindBlocks<'a>>(node: &'a T) -> Vec<String> {
560        node.descendant_blocks()
561            .map(|b| b.resolved_context().as_ref().to_string())
562            .collect()
563    }
564
565    #[test]
566    fn empty_document_has_no_descendants() {
567        let doc = Parser::default().parse("");
568        assert_eq!(doc.descendant_blocks().count(), 0);
569        assert!(doc.find_block_by_id("anything").is_none());
570    }
571
572    #[test]
573    fn document_order_and_nesting() {
574        // A section containing a list (which owns its items) and a source
575        // listing. The walk should yield each in document order, descending into
576        // the section and the list.
577        let doc = Parser::default()
578            .parse("== Section\n\n* one\n* two\n\n[source,rust]\n----\nfn main() {}\n----\n");
579
580        let contexts = contexts(&doc);
581
582        // Section, then the list, then each item and the paragraph it holds,
583        // then the listing.
584        assert_eq!(
585            contexts,
586            vec![
587                "section",
588                "list",
589                "list_item",
590                "paragraph",
591                "list_item",
592                "paragraph",
593                "listing"
594            ]
595        );
596    }
597
598    #[test]
599    fn descendant_blocks_compose_with_std_combinators() {
600        let doc = Parser::default().parse("== A\n\ntext\n\n== B\n\ntext\n");
601
602        let sections = doc
603            .descendant_blocks()
604            .filter(|b| matches!(b, Block::Section(_)))
605            .count();
606
607        assert_eq!(sections, 2);
608    }
609
610    #[test]
611    fn find_blocks_by_context_and_style() {
612        let doc = Parser::default()
613            .parse("[source,rust]\n----\nfn main() {}\n----\n\n----\nplain listing\n----\n");
614
615        // Both are listings.
616        assert_eq!(
617            doc.find_blocks(&BlockSelector::new().context("listing"))
618                .count(),
619            2
620        );
621
622        // Only one is a source listing.
623        let sources: Vec<_> = doc
624            .find_blocks(&BlockSelector::new().context("listing").style("source"))
625            .collect();
626        assert_eq!(sources.len(), 1);
627        assert_eq!(sources.first().unwrap().declared_style(), Some("source"));
628    }
629
630    #[test]
631    fn find_blocks_by_context_excludes_non_matching() {
632        // The paragraph is visited but excluded by the context filter, leaving
633        // only the listing.
634        let doc = Parser::default().parse("A paragraph.\n\n----\na listing\n----\n");
635
636        let listings: Vec<_> = doc
637            .find_blocks(&BlockSelector::new().context("listing"))
638            .collect();
639
640        assert_eq!(listings.len(), 1);
641        assert_eq!(
642            listings.first().unwrap().resolved_context().as_ref(),
643            "listing"
644        );
645    }
646
647    #[test]
648    fn find_blocks_by_id_selector() {
649        // Exercises the `id` selector field; the walk visits the non-matching
650        // "World." paragraph as well as the matching block.
651        let doc = Parser::default().parse("[#intro]\nHello.\n\nWorld.\n");
652
653        let matched: Vec<_> = doc.find_blocks(&BlockSelector::new().id("intro")).collect();
654
655        assert_eq!(matched.len(), 1);
656        assert_eq!(matched.first().unwrap().id(), Some("intro"));
657    }
658
659    #[test]
660    fn find_blocks_by_role() {
661        let doc = Parser::default().parse("[.important]\nAttention.\n\nOrdinary.\n");
662
663        let matched: Vec<_> = doc
664            .find_blocks(&BlockSelector::new().role("important"))
665            .collect();
666
667        assert_eq!(matched.len(), 1);
668        assert!(matched.first().unwrap().roles().contains(&"important"));
669    }
670
671    #[test]
672    fn find_block_by_id_hit_and_miss() {
673        let doc = Parser::default().parse("[#intro]\nHello.\n\nWorld.\n");
674
675        let intro = doc.find_block_by_id("intro").unwrap();
676        assert_eq!(intro.id(), Some("intro"));
677        assert!(doc.find_block_by_id("missing").is_none());
678    }
679
680    #[test]
681    fn traverse_blocks_accept_yields_everything() {
682        let doc = Parser::default().parse("== S\n\n* one\n* two\n");
683
684        let all: Vec<_> = doc.traverse_blocks(|_| Descend::Accept).collect();
685        let plain: Vec<_> = doc.descendant_blocks().collect();
686
687        assert_eq!(all, plain);
688    }
689
690    #[test]
691    fn traverse_blocks_prune_stops_at_matched_subtree() {
692        // A sidebar nested inside a sidebar, followed by a top-level paragraph.
693        // Prune on the outer sidebar collects it but does not descend (so the
694        // inner sidebar is not reported); the trailing paragraph is rejected.
695        let doc = Parser::default()
696            .parse("****\nOuter.\n\n[.inner]\n*****\nInner.\n*****\n****\n\nAfter.\n");
697
698        let sidebars: Vec<_> = doc
699            .traverse_blocks(|b| {
700                if b.resolved_context().as_ref() == "sidebar" {
701                    Descend::Prune
702                } else {
703                    Descend::Reject
704                }
705            })
706            .collect();
707
708        // Only the outer sidebar: the inner sidebar is behind the prune, and the
709        // trailing paragraph is rejected.
710        assert_eq!(sidebars.len(), 1);
711    }
712
713    #[test]
714    fn traverse_blocks_reject_excludes_block_and_children() {
715        // The counterpart to the skip case: rejecting the sidebar excludes it
716        // *and* the paragraph inside it, while the top-level sibling paragraph
717        // is still accepted.
718        let doc = Parser::default().parse("****\nInside.\n****\n\nOutside.\n");
719
720        let contexts: Vec<_> = doc
721            .traverse_blocks(|b| {
722                if b.resolved_context().as_ref() == "sidebar" {
723                    Descend::Reject
724                } else {
725                    Descend::Accept
726                }
727            })
728            .map(|b| b.resolved_context().as_ref().to_string())
729            .collect();
730
731        assert_eq!(contexts, vec!["paragraph"]);
732    }
733
734    #[test]
735    fn traverse_blocks_skip_excludes_block_but_visits_children() {
736        let doc = Parser::default().parse("****\nInside sidebar.\n****\n");
737
738        // Skip the sidebar itself but descend into it: we should see the inner
739        // paragraph but not the sidebar.
740        let contexts: Vec<_> = doc
741            .traverse_blocks(|b| {
742                if b.resolved_context().as_ref() == "sidebar" {
743                    Descend::Skip
744                } else {
745                    Descend::Accept
746                }
747            })
748            .map(|b| b.resolved_context().as_ref().to_string())
749            .collect();
750
751        assert_eq!(contexts, vec!["paragraph"]);
752    }
753
754    #[test]
755    fn markdown_quote_children_are_reached() {
756        // Regression: a Markdown-style blockquote's children borrow the block's
757        // own owned source rather than the document source, but the search API
758        // must still reach them (via `QuoteBlock::blocks()`).
759        let doc = Parser::default().parse("> A quoted paragraph.\n");
760
761        let contexts = contexts(&doc);
762        assert_eq!(contexts, vec!["quote", "paragraph"]);
763    }
764
765    #[test]
766    fn table_cells_require_traverse_documents() {
767        // An AsciiDoc (`a|`) cell whose content is its own nested document.
768        let doc = Parser::default().parse("|===\na| Cell _text_.\n|===\n");
769
770        // By default the walk stops at the table.
771        assert_eq!(doc.find_blocks(&BlockSelector::new()).count(), 1);
772        assert_eq!(
773            doc.find_blocks(&BlockSelector::new().context("table"))
774                .count(),
775            1
776        );
777
778        // Opting in reaches the paragraph inside the cell.
779        assert_eq!(
780            doc.find_blocks(&BlockSelector::new().traverse_documents(true))
781                .count(),
782            2
783        );
784    }
785
786    #[test]
787    fn traverse_documents_skips_non_asciidoc_cells() {
788        // One plain cell and one AsciiDoc (`a|`) cell. With `traverse_documents`
789        // the plain cell contributes no blocks (its content is inline), while the
790        // AsciiDoc cell contributes its paragraph.
791        let doc = Parser::default().parse("|===\n| plain\na| AsciiDoc _text_.\n|===\n");
792
793        let deep = doc
794            .find_blocks(&BlockSelector::new().traverse_documents(true))
795            .count();
796
797        // The table plus the one paragraph from the AsciiDoc cell.
798        assert_eq!(deep, 2);
799    }
800
801    #[test]
802    fn find_blocks_on_a_block_searches_its_subtree() {
803        // The API is available on `Block`, not only `Document`.
804        let doc = Parser::default().parse("== Section\n\n* one\n* two\n");
805
806        let section = doc
807            .descendant_blocks()
808            .find(|b| matches!(b, Block::Section(_)))
809            .unwrap();
810
811        // The section's own descendants: the list, and each item with the
812        // paragraph it holds.
813        assert_eq!(
814            contexts(section),
815            vec!["list", "list_item", "paragraph", "list_item", "paragraph"]
816        );
817    }
818
819    #[test]
820    fn child_blocks_yields_direct_children_only() {
821        // `child_blocks()` is one level deep: the document's only direct child
822        // is the section, not the list and paragraphs nested inside it.
823        let doc = Parser::default().parse("== Section\n\n* one\n* two\n");
824
825        let child_contexts: Vec<_> = doc
826            .child_blocks()
827            .map(|b| b.resolved_context().as_ref().to_string())
828            .collect();
829
830        assert_eq!(child_contexts, vec!["section"]);
831
832        // On a `Block`, `child_blocks()` returns that block's own direct
833        // children (the section's list), still without recursing.
834        let section = doc.child_blocks().next().unwrap();
835        let section_child_contexts: Vec<_> = section
836            .child_blocks()
837            .map(|b| b.resolved_context().as_ref().to_string())
838            .collect();
839
840        assert_eq!(section_child_contexts, vec!["list"]);
841    }
842
843    #[test]
844    fn child_blocks_reaches_markdown_quote_children() {
845        // Regression for #894: a Markdown-style blockquote's children are not
846        // exposed through a bare structural walk, but `child_blocks()` reaches
847        // them.
848        let doc = Parser::default().parse("> A quoted paragraph.\n");
849
850        let quote = doc.child_blocks().next().unwrap();
851        assert_eq!(quote.resolved_context().as_ref(), "quote");
852
853        let quote_child_contexts: Vec<_> = quote
854            .child_blocks()
855            .map(|b| b.resolved_context().as_ref().to_string())
856            .collect();
857
858        assert_eq!(quote_child_contexts, vec!["paragraph"]);
859    }
860
861    #[test]
862    fn leaf_block_types_report_no_child_blocks() {
863        // Every leaf block type answers its inherent `child_blocks()` with an
864        // empty iterator.
865        // The trailing section (a non-leaf block) exercises the `_` arm below.
866        let doc = Parser::default().parse(
867            "A paragraph.\n\nimage::sunset.jpg[]\n\n----\nlisting\n----\n\n|===\n|cell\n|===\n\n'''\n\n== Section\n\nInside.\n",
868        );
869
870        let mut saw_simple = false;
871        let mut saw_media = false;
872        let mut saw_raw = false;
873        let mut saw_table = false;
874        let mut saw_break = false;
875
876        for block in doc.descendant_blocks() {
877            match block {
878                Block::Simple(b) => {
879                    assert_eq!(b.child_blocks().count(), 0);
880                    saw_simple = true;
881                }
882                Block::Media(b) => {
883                    assert_eq!(b.child_blocks().count(), 0);
884                    saw_media = true;
885                }
886                Block::RawDelimited(b) => {
887                    assert_eq!(b.child_blocks().count(), 0);
888                    saw_raw = true;
889                }
890                Block::Table(b) => {
891                    assert_eq!(b.child_blocks().count(), 0);
892                    saw_table = true;
893                }
894                Block::Break(b) => {
895                    assert_eq!(b.child_blocks().count(), 0);
896                    saw_break = true;
897                }
898                _ => {}
899            }
900        }
901
902        // Ensure the fixture actually exercised each leaf type.
903        assert!(saw_simple && saw_media && saw_raw && saw_table && saw_break);
904    }
905
906    #[test]
907    fn child_blocks_does_not_enter_table_cells() {
908        // A table reports no direct child blocks; its AsciiDoc cell content is a
909        // separate nested document, reachable only through `find_blocks` with
910        // `traverse_documents`.
911        let doc = Parser::default().parse("|===\na| Cell _text_.\n|===\n");
912
913        let table = doc.child_blocks().next().unwrap();
914        assert_eq!(table.resolved_context().as_ref(), "table");
915        assert_eq!(table.child_blocks().count(), 0);
916    }
917}