Skip to main content

asciidoc_parser/blocks/
admonition.rs

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