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#[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 pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
59 self.title.as_mut()
60 }
61
62 pub(crate) fn parse(
68 metadata: &BlockMetadata<'src>,
69 parser: &mut Parser,
70 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
71 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 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 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 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 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 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 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 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 MatchAndWarnings {
211 item: None,
212 warnings: vec![],
213 }
214 }
215
216 #[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 pub fn variant(&self) -> AdmonitionVariant {
258 self.variant
259 }
260
261 pub fn name(&self) -> &'static str {
263 self.variant.name()
264 }
265
266 pub fn label(&self) -> &str {
272 &self.label
273 }
274
275 pub fn icons_font(&self) -> bool {
278 self.icons_font
279 }
280
281 pub fn content(&self) -> Option<&Content<'src>> {
284 self.content.as_ref()
285 }
286}
287
288fn 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
303fn 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
313fn icons_are_font(parser: &Parser) -> bool {
315 matches!(parser.attribute_value("icons"), InterpretedValue::Value(value) if value == "font")
316}
317
318pub(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
348pub(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 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 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 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 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 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 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 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 let mut parser = Parser::default();
739 let maw = Block::parse(crate::Span::new("[NOTE]\n"), &mut parser);
740
741 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 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 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}