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}