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#[derive(Clone, Eq, 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 pub fn child_blocks(&'src self) -> ChildBlocks<'src> {
56 ChildBlocks::from_slice(&self.blocks)
57 }
58
59 pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
67 self.title.as_mut()
68 }
69
70 pub(crate) fn parse(
76 metadata: &BlockMetadata<'src>,
77 parser: &mut Parser,
78 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
79 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 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 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 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 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 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 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 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 MatchAndWarnings {
219 item: None,
220 warnings: vec![],
221 }
222 }
223
224 #[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 pub fn variant(&self) -> AdmonitionVariant {
266 self.variant
267 }
268
269 pub fn name(&self) -> &'static str {
271 self.variant.name()
272 }
273
274 pub fn label(&self) -> &str {
280 &self.label
281 }
282
283 pub fn icons_font(&self) -> bool {
286 self.icons_font
287 }
288
289 pub fn content(&self) -> Option<&Content<'src>> {
292 self.content.as_ref()
293 }
294}
295
296fn 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
311fn 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
321fn icons_are_font(parser: &Parser) -> bool {
323 matches!(parser.attribute_value("icons"), InterpretedValue::Value(value) if value == "font")
324}
325
326pub(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
356pub(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 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 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 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 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 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 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 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 let mut parser = Parser::default();
744 let maw = Block::parse(crate::Span::new("[NOTE]\n"), &mut parser);
745
746 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 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 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}