Skip to main content

asciidoc_parser/blocks/
admonition.rs

1use std::slice::Iter;
2
3use crate::{
4    HasSpan, Parser, Span,
5    attributes::Attrlist,
6    blocks::{
7        AdmonitionVariant, Block, CompoundDelimitedBlock, ContentModel, IsBlock, RawDelimitedBlock,
8        SimpleBlock, TableBlock, metadata::BlockMetadata,
9    },
10    content::Content,
11    document::InterpretedValue,
12    internal::debug::DebugSliceReference,
13    span::MatchedItem,
14    strings::CowStr,
15    warnings::MatchAndWarnings,
16};
17
18/// An admonition draws attention to a statement by taking it out of the
19/// content's flow and labeling it with a priority (its type).
20///
21/// An admonition can be written in two ways:
22///
23/// * As a **paragraph** whose first line begins with one of the five admonition
24///   labels followed by a colon (e.g., `NOTE: This is a note.`). This produces
25///   an admonition with the [`Simple`] content model.
26/// * As a **block** by setting one of the labels as the block style in an
27///   attribute list (e.g., `[NOTE]`). This is referred to as *masquerading*.
28///   When the style is set on a delimited block that can contain other blocks
29///   (such as an example block), the admonition uses the [`Compound`] content
30///   model; otherwise it uses the [`Simple`] content model.
31///
32/// [`Simple`]: ContentModel::Simple
33/// [`Compound`]: ContentModel::Compound
34#[derive(Clone, Eq, PartialEq)]
35pub struct AdmonitionBlock<'src> {
36    variant: AdmonitionVariant,
37    label: String,
38    icons_font: bool,
39    content_model: ContentModel,
40    content: Option<Content<'src>>,
41    blocks: Vec<Block<'src>>,
42    source: Span<'src>,
43    title_source: Option<Span<'src>>,
44    title: Option<Content<'src>>,
45    anchor: Option<Span<'src>>,
46    anchor_reftext: Option<Span<'src>>,
47    attrlist: Option<Attrlist<'src>>,
48}
49
50impl<'src> AdmonitionBlock<'src> {
51    /// Returns the block's title as a mutable [`Content`], if the block has
52    /// one.
53    ///
54    /// This narrow seam exists for the document-order title resolution pass
55    /// (see `document::title_refs`), which installs the re-rendered title
56    /// after resolving any cross-references embedded in it. All other access
57    /// goes through the read-only [`IsBlock::title`] accessor.
58    pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
59        self.title.as_mut()
60    }
61
62    /// Parse an admonition block, if the given metadata and content describe
63    /// one.
64    ///
65    /// Returns `None` (without consuming the block) when the content is not an
66    /// admonition, so that the caller can fall through to other block parsers.
67    pub(crate) fn parse(
68        metadata: &BlockMetadata<'src>,
69        parser: &mut Parser,
70    ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
71        // Masquerade form: the block style names an admonition type.
72        if let Some(style) = metadata.attrlist.as_ref().and_then(|a| a.block_style())
73            && let Some(variant) = AdmonitionVariant::from_style(style)
74        {
75            return Some(Self::parse_masquerade(metadata, parser, variant));
76        }
77
78        // Paragraph form: the first line begins with `LABEL: `. This form does
79        // not apply when a block style was declared (that would be a
80        // masquerade), so it is only reached when there is no admonition style.
81        if metadata
82            .attrlist
83            .as_ref()
84            .and_then(|a| a.block_style())
85            .is_none()
86            && let Some((variant, content_start)) =
87                admonition_paragraph_prefix(metadata.block_start)
88        {
89            return Some(Self::parse_paragraph(
90                metadata,
91                parser,
92                variant,
93                content_start,
94            ));
95        }
96
97        None
98    }
99
100    /// Parse the masquerade form, where the admonition style is set on a block.
101    fn parse_masquerade(
102        metadata: &BlockMetadata<'src>,
103        parser: &mut Parser,
104        variant: AdmonitionVariant,
105    ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
106        let first_line = metadata.block_start.take_normalized_line().item;
107
108        // An admonition style masquerades only over an example block, an open
109        // block, or a paragraph. Over an example or open block it yields
110        // compound content.
111        //
112        // For a valid example/open delimiter, `CompoundDelimitedBlock::parse`
113        // always returns `item: Some(..)` — even for an unterminated block, in
114        // which case it also reports an `UnterminatedDelimitedBlock` warning.
115        // Those `warnings` are bound here and forwarded through `finish`, so no
116        // diagnostic is lost on the masquerade path.
117        if is_example_or_open_delimiter(&first_line)
118            && let Some(MatchAndWarnings {
119                item: Some(inner),
120                warnings,
121            }) = CompoundDelimitedBlock::parse(metadata, parser)
122        {
123            let after = inner.after;
124            let blocks = inner.item.into_nested_blocks();
125            return Self::finish(
126                metadata,
127                parser,
128                variant,
129                ContentModel::Compound,
130                None,
131                blocks,
132                after,
133                warnings,
134            );
135        }
136
137        // Any other structural container (sidebar, quote, listing, literal,
138        // passthrough, table, comment) keeps its own context and ignores the
139        // admonition style, so this is not an admonition.
140        if RawDelimitedBlock::is_valid_delimiter(&first_line)
141            || CompoundDelimitedBlock::is_valid_delimiter(&first_line)
142            || TableBlock::is_table_delimiter(&first_line)
143        {
144            return MatchAndWarnings {
145                item: None,
146                warnings: vec![],
147            };
148        }
149
150        // Otherwise the style is applied to a paragraph: simple content.
151        if let Some(inner) = SimpleBlock::parse(metadata, parser) {
152            return Self::finish(
153                metadata,
154                parser,
155                variant,
156                ContentModel::Simple,
157                Some(inner.item.content().clone()),
158                vec![],
159                inner.after,
160                vec![],
161            );
162        }
163
164        MatchAndWarnings {
165            item: None,
166            warnings: vec![],
167        }
168    }
169
170    /// Parse the paragraph form, where the first line begins with `LABEL: `.
171    fn parse_paragraph(
172        metadata: &BlockMetadata<'src>,
173        parser: &mut Parser,
174        variant: AdmonitionVariant,
175        content_start: Span<'src>,
176    ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
177        // Build a metadata that begins after the stripped label so that the
178        // paragraph content is parsed without it. The admonition retains the
179        // outer block's title, anchor, and attribute list.
180        let inner_metadata = BlockMetadata {
181            title_source: None,
182            title: None,
183            anchor: None,
184            anchor_reftext: None,
185            attrlist: None,
186            source: content_start,
187            block_start: content_start,
188        };
189
190        if let Some(inner) = SimpleBlock::parse(&inner_metadata, parser) {
191            return Self::finish(
192                metadata,
193                parser,
194                variant,
195                ContentModel::Simple,
196                Some(inner.item.content().clone()),
197                vec![],
198                inner.after,
199                vec![],
200            );
201        }
202
203        // Unreachable in practice: `parse_paragraph` is only called after
204        // `admonition_paragraph_prefix` matched, which requires a non-whitespace
205        // character after the label on the first line (trailing whitespace is
206        // trimmed before the match). `content_start` therefore points at
207        // non-empty content, so `SimpleBlock::parse` always returns `Some`. This
208        // fall-through is kept as a defensive default that mirrors
209        // `parse_masquerade`.
210        MatchAndWarnings {
211            item: None,
212            warnings: vec![],
213        }
214    }
215
216    /// Assemble the final admonition block from its parsed parts, resolving the
217    /// caption and icon state from the current document attributes.
218    #[allow(clippy::too_many_arguments)]
219    fn finish(
220        metadata: &BlockMetadata<'src>,
221        parser: &Parser,
222        variant: AdmonitionVariant,
223        content_model: ContentModel,
224        content: Option<Content<'src>>,
225        blocks: Vec<Block<'src>>,
226        after: Span<'src>,
227        warnings: Vec<crate::warnings::Warning<'src>>,
228    ) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
229        let source = metadata
230            .source
231            .trim_remainder(after)
232            .trim_trailing_whitespace();
233
234        MatchAndWarnings {
235            item: Some(MatchedItem {
236                item: Self {
237                    variant,
238                    label: resolve_caption(variant, parser),
239                    icons_font: icons_are_font(parser),
240                    content_model,
241                    content,
242                    blocks,
243                    source,
244                    title_source: metadata.title_source,
245                    title: metadata.title.clone(),
246                    anchor: metadata.anchor,
247                    anchor_reftext: metadata.anchor_reftext,
248                    attrlist: metadata.attrlist.clone(),
249                },
250                after,
251            }),
252            warnings,
253        }
254    }
255
256    /// Returns the admonition type (e.g., [`AdmonitionVariant::Note`]).
257    pub fn variant(&self) -> AdmonitionVariant {
258        self.variant
259    }
260
261    /// Returns the lowercase name for this admonition (e.g., `note`).
262    pub fn name(&self) -> &'static str {
263        self.variant.name()
264    }
265
266    /// Returns the caption (label) text shown for this admonition (e.g.,
267    /// `Note`).
268    ///
269    /// This is the value of the `<type>-caption` document attribute if set, or
270    /// the default caption for the admonition type otherwise.
271    pub fn label(&self) -> &str {
272        &self.label
273    }
274
275    /// Returns `true` if font-based icons are enabled (i.e., the `icons`
276    /// document attribute is set to `font`).
277    pub fn icons_font(&self) -> bool {
278        self.icons_font
279    }
280
281    /// Returns the simple content of this admonition, if it has the
282    /// [`Simple`](ContentModel::Simple) content model.
283    pub fn content(&self) -> Option<&Content<'src>> {
284        self.content.as_ref()
285    }
286}
287
288/// Returns `true` if `line` is the opening delimiter of an example block
289/// (`====`) or an open block (`--`).
290///
291/// These are the only delimited blocks over which an admonition style may
292/// masquerade.
293fn is_example_or_open_delimiter(line: &Span<'_>) -> bool {
294    let data = line.data();
295
296    if data == "--" {
297        return true;
298    }
299
300    data.len() >= 4 && data.chars().all(|c| c == '=')
301}
302
303/// Resolve the caption (label) text for an admonition variant from the current
304/// document attributes, falling back to the variant's default caption.
305fn resolve_caption(variant: AdmonitionVariant, parser: &Parser) -> String {
306    let key = format!("{}-caption", variant.name());
307    match parser.attribute_value(key) {
308        InterpretedValue::Value(value) => value,
309        _ => variant.default_caption().to_string(),
310    }
311}
312
313/// Returns `true` if the `icons` document attribute is set to `font`.
314fn icons_are_font(parser: &Parser) -> bool {
315    matches!(parser.attribute_value("icons"), InterpretedValue::Value(value) if value == "font")
316}
317
318/// If the first line of `block_start` begins with an admonition label followed
319/// by a colon and at least one space (e.g., `NOTE: `), return the admonition
320/// variant and a span positioned at the start of the paragraph content (after
321/// the stripped label).
322pub(crate) fn admonition_paragraph_prefix(
323    block_start: Span<'_>,
324) -> Option<(AdmonitionVariant, Span<'_>)> {
325    let line = block_start.take_normalized_line().item;
326    let data = line.data();
327
328    for variant in [
329        AdmonitionVariant::Note,
330        AdmonitionVariant::Tip,
331        AdmonitionVariant::Important,
332        AdmonitionVariant::Caution,
333        AdmonitionVariant::Warning,
334    ] {
335        if let Some(rest) = data.strip_prefix(variant.style())
336            && let Some(rest) = rest.strip_prefix(':')
337            && (rest.starts_with(' ') || rest.starts_with('\t'))
338        {
339            let whitespace_len = rest.len() - rest.trim_start_matches([' ', '\t']).len();
340            let prefix_len = variant.style().len() + 1 + whitespace_len;
341            return Some((variant, block_start.discard(prefix_len)));
342        }
343    }
344
345    None
346}
347
348/// Returns `true` if the first line of `line` begins with an admonition
349/// paragraph label (e.g., `NOTE: `).
350pub(crate) fn starts_with_admonition_label(line: Span<'_>) -> bool {
351    admonition_paragraph_prefix(line).is_some()
352}
353
354impl<'src> IsBlock<'src> for AdmonitionBlock<'src> {
355    fn content_model(&self) -> ContentModel {
356        self.content_model
357    }
358
359    fn raw_context(&self) -> CowStr<'src> {
360        "admonition".into()
361    }
362
363    fn declared_style(&'src self) -> Option<&'src str> {
364        Some(self.variant.style())
365    }
366
367    fn rendered_content(&'src self) -> Option<&'src str> {
368        self.content.as_ref().map(|content| content.rendered())
369    }
370
371    fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
372        self.blocks.iter()
373    }
374
375    fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
376        &mut self.blocks
377    }
378
379    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
380        self.content.as_mut()
381    }
382
383    fn title_source(&'src self) -> Option<Span<'src>> {
384        self.title_source
385    }
386
387    fn title(&self) -> Option<&str> {
388        self.title.as_ref().map(Content::rendered_str)
389    }
390
391    fn anchor(&'src self) -> Option<Span<'src>> {
392        self.anchor
393    }
394
395    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
396        self.anchor_reftext
397    }
398
399    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
400        self.attrlist.as_ref()
401    }
402}
403
404impl<'src> HasSpan<'src> for AdmonitionBlock<'src> {
405    fn span(&self) -> Span<'src> {
406        self.source
407    }
408}
409
410impl std::fmt::Debug for AdmonitionBlock<'_> {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        f.debug_struct("AdmonitionBlock")
413            .field("variant", &self.variant)
414            .field("label", &self.label)
415            .field("icons_font", &self.icons_font)
416            .field("content_model", &self.content_model)
417            .field("content", &self.content)
418            .field("blocks", &DebugSliceReference(&self.blocks))
419            .field("source", &self.source)
420            .field("title_source", &self.title_source)
421            .field("title", &self.title)
422            .field("anchor", &self.anchor)
423            .field("anchor_reftext", &self.anchor_reftext)
424            .field("attrlist", &self.attrlist)
425            .finish()
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    #![allow(clippy::unwrap_used)]
432    #![allow(clippy::panic)]
433
434    use std::ops::Deref;
435
436    use crate::{
437        blocks::{AdmonitionVariant, Block, ContentModel, IsBlock},
438        tests::prelude::*,
439    };
440
441    fn parse_one(input: &'static str) -> Block<'static> {
442        let mut parser = Parser::default();
443        Block::parse(crate::Span::new(input), &mut parser)
444            .unwrap_if_no_warnings()
445            .unwrap()
446            .item
447    }
448
449    fn as_admonition<'a>(block: &'a Block<'a>) -> &'a crate::blocks::AdmonitionBlock<'a> {
450        match block {
451            Block::Admonition(admonition) => admonition,
452            // Only reached if a test parses an input that is not an admonition;
453            // it exists to fail that test loudly, so it is uncovered while the
454            // tests pass.
455            other => panic!("expected an admonition block, got {other:?}"),
456        }
457    }
458
459    mod admonition_variant {
460        use crate::blocks::AdmonitionVariant;
461
462        #[test]
463        fn style() {
464            assert_eq!(AdmonitionVariant::Note.style(), "NOTE");
465            assert_eq!(AdmonitionVariant::Tip.style(), "TIP");
466            assert_eq!(AdmonitionVariant::Important.style(), "IMPORTANT");
467            assert_eq!(AdmonitionVariant::Caution.style(), "CAUTION");
468            assert_eq!(AdmonitionVariant::Warning.style(), "WARNING");
469        }
470
471        #[test]
472        fn name() {
473            assert_eq!(AdmonitionVariant::Note.name(), "note");
474            assert_eq!(AdmonitionVariant::Tip.name(), "tip");
475            assert_eq!(AdmonitionVariant::Important.name(), "important");
476            assert_eq!(AdmonitionVariant::Caution.name(), "caution");
477            assert_eq!(AdmonitionVariant::Warning.name(), "warning");
478        }
479
480        #[test]
481        fn default_caption() {
482            assert_eq!(AdmonitionVariant::Note.default_caption(), "Note");
483            assert_eq!(AdmonitionVariant::Tip.default_caption(), "Tip");
484            assert_eq!(AdmonitionVariant::Important.default_caption(), "Important");
485            assert_eq!(AdmonitionVariant::Caution.default_caption(), "Caution");
486            assert_eq!(AdmonitionVariant::Warning.default_caption(), "Warning");
487        }
488
489        #[test]
490        fn from_style() {
491            assert_eq!(
492                AdmonitionVariant::from_style("NOTE"),
493                Some(AdmonitionVariant::Note)
494            );
495            assert_eq!(
496                AdmonitionVariant::from_style("TIP"),
497                Some(AdmonitionVariant::Tip)
498            );
499            assert_eq!(
500                AdmonitionVariant::from_style("IMPORTANT"),
501                Some(AdmonitionVariant::Important)
502            );
503            assert_eq!(
504                AdmonitionVariant::from_style("CAUTION"),
505                Some(AdmonitionVariant::Caution)
506            );
507            assert_eq!(
508                AdmonitionVariant::from_style("WARNING"),
509                Some(AdmonitionVariant::Warning)
510            );
511
512            // The match is case-sensitive and rejects non-labels.
513            assert_eq!(AdmonitionVariant::from_style("note"), None);
514            assert_eq!(AdmonitionVariant::from_style("Note"), None);
515            assert_eq!(AdmonitionVariant::from_style("example"), None);
516        }
517
518        #[test]
519        fn impl_debug() {
520            assert_eq!(
521                format!("{:?}", AdmonitionVariant::Note),
522                "AdmonitionVariant::Note"
523            );
524            assert_eq!(
525                format!("{:?}", AdmonitionVariant::Tip),
526                "AdmonitionVariant::Tip"
527            );
528            assert_eq!(
529                format!("{:?}", AdmonitionVariant::Important),
530                "AdmonitionVariant::Important"
531            );
532            assert_eq!(
533                format!("{:?}", AdmonitionVariant::Caution),
534                "AdmonitionVariant::Caution"
535            );
536            assert_eq!(
537                format!("{:?}", AdmonitionVariant::Warning),
538                "AdmonitionVariant::Warning"
539            );
540        }
541
542        #[test]
543        fn impl_clone() {
544            // Silly test to mark the #[derive(...)] line as covered.
545            let v1 = AdmonitionVariant::Note;
546            let v2 = v1;
547            assert_eq!(v1, v2);
548        }
549    }
550
551    #[test]
552    fn paragraph_form() {
553        let block = parse_one("NOTE: This is a note.");
554        let admonition = as_admonition(&block);
555
556        assert_eq!(admonition.variant(), AdmonitionVariant::Note);
557        assert_eq!(admonition.name(), "note");
558        assert_eq!(admonition.label(), "Note");
559        assert!(!admonition.icons_font());
560        assert_eq!(admonition.content_model(), ContentModel::Simple);
561        assert_eq!(admonition.content().unwrap().rendered(), "This is a note.");
562        assert_eq!(admonition.rendered_content(), Some("This is a note."));
563        assert_eq!(admonition.raw_context().deref(), "admonition");
564        assert_eq!(admonition.resolved_context().deref(), "admonition");
565        assert_eq!(admonition.declared_style(), Some("NOTE"));
566        assert!(admonition.nested_blocks().next().is_none());
567        assert!(admonition.title().is_none());
568        assert!(admonition.attrlist().is_none());
569    }
570
571    #[test]
572    fn paragraph_form_all_variants() {
573        for (input, variant, name) in [
574            ("NOTE: x", AdmonitionVariant::Note, "note"),
575            ("TIP: x", AdmonitionVariant::Tip, "tip"),
576            ("IMPORTANT: x", AdmonitionVariant::Important, "important"),
577            ("CAUTION: x", AdmonitionVariant::Caution, "caution"),
578            ("WARNING: x", AdmonitionVariant::Warning, "warning"),
579        ] {
580            let block = parse_one(input);
581            let admonition = as_admonition(&block);
582            assert_eq!(admonition.variant(), variant);
583            assert_eq!(admonition.name(), name);
584        }
585    }
586
587    #[test]
588    fn paragraph_form_multiline() {
589        let block = parse_one("NOTE: first line\nsecond line");
590        let admonition = as_admonition(&block);
591        assert_eq!(
592            admonition.content().unwrap().rendered(),
593            "first line\nsecond line"
594        );
595    }
596
597    #[test]
598    fn paragraph_form_tab_separator() {
599        let block = parse_one("NOTE:\tindented with a tab");
600        let admonition = as_admonition(&block);
601        assert_eq!(admonition.variant(), AdmonitionVariant::Note);
602        assert_eq!(
603            admonition.content().unwrap().rendered(),
604            "indented with a tab"
605        );
606    }
607
608    #[test]
609    fn not_an_admonition_when_lowercase_label() {
610        let block = parse_one("note: not an admonition");
611        assert_eq!(block.raw_context().deref(), "paragraph");
612    }
613
614    #[test]
615    fn not_an_admonition_without_separating_space() {
616        let block = parse_one("NOTE:no space");
617        assert_eq!(block.raw_context().deref(), "paragraph");
618    }
619
620    #[test]
621    fn not_an_admonition_for_partial_label() {
622        let block = parse_one("NOTES: a longer word");
623        assert_eq!(block.raw_context().deref(), "paragraph");
624    }
625
626    #[test]
627    fn indented_label_is_a_literal_block_not_an_admonition() {
628        // Indented content is a literal block; the leading whitespace means the
629        // line is not an admonition paragraph.
630        let block = parse_one("  NOTE: indented text");
631        assert_eq!(block.raw_context().deref(), "paragraph");
632    }
633
634    #[test]
635    fn masquerade_on_example_block_is_compound() {
636        let block = parse_one("[NOTE]\n====\nCompound content.\n====");
637        let admonition = as_admonition(&block);
638        assert_eq!(admonition.variant(), AdmonitionVariant::Note);
639        assert_eq!(admonition.content_model(), ContentModel::Compound);
640        assert!(admonition.content().is_none());
641        assert!(admonition.rendered_content().is_none());
642        assert_eq!(admonition.nested_blocks().count(), 1);
643    }
644
645    #[test]
646    fn masquerade_propagates_unterminated_warning() {
647        // An unterminated example block under an admonition style still parses
648        // as a (compound) admonition, and its `UnterminatedDelimitedBlock`
649        // warning is not swallowed by the masquerade path.
650        let mut parser = Parser::default();
651        let maw = Block::parse(crate::Span::new("[NOTE]\n====\nunclosed"), &mut parser);
652
653        let block = maw.item.unwrap().item;
654        assert_eq!(block.raw_context().deref(), "admonition");
655        assert_eq!(maw.warnings.len(), 1);
656        assert_eq!(
657            maw.warnings.first().unwrap().warning,
658            crate::warnings::WarningType::UnterminatedDelimitedBlock
659        );
660    }
661
662    #[test]
663    fn masquerade_on_open_block_is_compound() {
664        let block = parse_one("[WARNING]\n--\npara one\n\npara two\n--");
665        let admonition = as_admonition(&block);
666        assert_eq!(admonition.variant(), AdmonitionVariant::Warning);
667        assert_eq!(admonition.content_model(), ContentModel::Compound);
668        assert_eq!(admonition.nested_blocks().count(), 2);
669    }
670
671    #[test]
672    fn masquerade_on_paragraph_is_simple() {
673        let block = parse_one("[TIP]\nA single paragraph.");
674        let admonition = as_admonition(&block);
675        assert_eq!(admonition.variant(), AdmonitionVariant::Tip);
676        assert_eq!(admonition.content_model(), ContentModel::Simple);
677        assert_eq!(
678            admonition.content().unwrap().rendered(),
679            "A single paragraph."
680        );
681    }
682
683    #[test]
684    fn masquerade_with_title() {
685        let block = parse_one("[NOTE]\n.A title\n====\nContent.\n====");
686        let admonition = as_admonition(&block);
687        assert_eq!(admonition.title(), Some("A title"));
688    }
689
690    #[test]
691    fn masquerade_does_not_apply_to_other_containers() {
692        // Sidebar, quote, listing, literal, and passthrough blocks keep their
693        // own context; the admonition style is ignored.
694        assert_eq!(
695            parse_one("[NOTE]\n****\nx\n****").raw_context().deref(),
696            "sidebar"
697        );
698        assert_eq!(
699            parse_one("[NOTE]\n____\nx\n____").raw_context().deref(),
700            "quote"
701        );
702        assert_eq!(
703            parse_one("[NOTE]\n----\nx\n----").raw_context().deref(),
704            "listing"
705        );
706        assert_eq!(
707            parse_one("[NOTE]\n....\nx\n....").raw_context().deref(),
708            "literal"
709        );
710        assert_eq!(
711            parse_one("[NOTE]\n++++\nx\n++++").raw_context().deref(),
712            "pass"
713        );
714    }
715
716    #[test]
717    fn impl_debug() {
718        let block = parse_one("NOTE: hi");
719        let admonition = as_admonition(&block);
720        let debug = format!("{admonition:?}");
721        assert!(debug.starts_with("AdmonitionBlock {"));
722        assert!(debug.contains("variant: AdmonitionVariant::Note"));
723    }
724
725    #[test]
726    fn impl_clone() {
727        // Silly test to mark the #[derive(...)] line as covered.
728        let block = parse_one("NOTE: clone me");
729        let admonition = as_admonition(&block).clone();
730        assert_eq!(admonition.variant(), AdmonitionVariant::Note);
731    }
732
733    #[test]
734    fn masquerade_without_content_is_not_an_admonition() {
735        // A masquerade style with no following block content cannot form an
736        // admonition; the masquerade parser reports no block and the line is
737        // treated as an ordinary block with a missing-block warning.
738        let mut parser = Parser::default();
739        let maw = Block::parse(crate::Span::new("[NOTE]\n"), &mut parser);
740
741        // The lone attribute list has no block to attach to: a missing-block
742        // warning is emitted and the line is treated as an ordinary paragraph,
743        // not an admonition.
744        let block = maw.item.unwrap().item;
745        assert_eq!(block.raw_context().deref(), "paragraph");
746        assert_eq!(maw.warnings.len(), 1);
747        assert_eq!(
748            maw.warnings.first().unwrap().warning,
749            crate::warnings::WarningType::MissingBlockAfterTitleOrAttributeList
750        );
751    }
752
753    #[test]
754    fn block_enum_delegates_to_admonition() {
755        // Exercise the `Block`-level `IsBlock`/`Debug` arms for an admonition
756        // (rather than calling through the unwrapped `AdmonitionBlock`).
757        let simple = parse_one("NOTE: text");
758        assert_eq!(simple.content_model(), ContentModel::Simple);
759        assert_eq!(simple.rendered_content(), Some("text"));
760        assert_eq!(simple.raw_context().deref(), "admonition");
761        assert_eq!(simple.declared_style(), Some("NOTE"));
762        assert!(simple.title_source().is_none());
763        assert!(simple.anchor_reftext().is_none());
764        assert_eq!(simple.substitution_group(), SubstitutionGroup::Normal);
765        assert!(simple.nested_blocks().next().is_none());
766        assert!(format!("{simple:?}").starts_with("Block::Admonition"));
767
768        let compound = parse_one("[NOTE]\n====\nx\n====");
769        assert_eq!(compound.content_model(), ContentModel::Compound);
770        assert_eq!(compound.nested_blocks().count(), 1);
771    }
772
773    #[test]
774    fn block_declared_style_for_non_admonition_kinds() {
775        // The `Block::declared_style` delegation returns `None` for block kinds
776        // that have no positional style: a media block, a list item, and a
777        // document-attribute block.
778        let media = parse_one("image::a.png[]");
779        assert_eq!(media.raw_context().deref(), "image");
780        assert!(media.declared_style().is_none());
781
782        let list = parse_one("* item");
783        let item = list.nested_blocks().next().unwrap();
784        assert_eq!(item.raw_context().deref(), "list_item");
785        assert!(item.declared_style().is_none());
786
787        let attribute = parse_one(":foo: bar");
788        assert_eq!(attribute.raw_context().deref(), "attribute");
789        assert!(attribute.declared_style().is_none());
790    }
791
792    mod caption_and_icons {
793        use crate::tests::prelude::*;
794
795        #[test]
796        fn default_caption_when_icons_unset() {
797            let doc = Parser::default().parse("CAUTION: Slippery when wet.");
798            assert_xpath(
799                &doc,
800                "//td[@class=\"icon\"]/div[@class=\"title\"][text()=\"Caution\"]",
801                1,
802            );
803        }
804
805        #[test]
806        fn caption_override_via_attribute() {
807            let doc = Parser::default().parse(":note-caption: Remarque\n\nNOTE: Bonjour.");
808            assert_xpath(
809                &doc,
810                "//td[@class=\"icon\"]/div[@class=\"title\"][text()=\"Remarque\"]",
811                1,
812            );
813        }
814
815        #[test]
816        fn font_icons_when_enabled() {
817            let doc = Parser::default().parse(":icons: font\n\nNOTE: This is a note.");
818            assert_css(&doc, "td.icon i.fa.icon-note", 1);
819            assert_css(&doc, "td.icon .title", 0);
820        }
821    }
822}