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