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<String>,
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 parse(
57 metadata: &BlockMetadata<'src>,
58 parser: &mut Parser,
59 ) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
60 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 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 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 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 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 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 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 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 MatchAndWarnings {
200 item: None,
201 warnings: vec![],
202 }
203 }
204
205 #[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 pub fn variant(&self) -> AdmonitionVariant {
247 self.variant
248 }
249
250 pub fn name(&self) -> &'static str {
252 self.variant.name()
253 }
254
255 pub fn label(&self) -> &str {
261 &self.label
262 }
263
264 pub fn icons_font(&self) -> bool {
267 self.icons_font
268 }
269
270 pub fn content(&self) -> Option<&Content<'src>> {
273 self.content.as_ref()
274 }
275}
276
277fn 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
292fn 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
302fn icons_are_font(parser: &Parser) -> bool {
304 matches!(parser.attribute_value("icons"), InterpretedValue::Value(value) if value == "font")
305}
306
307pub(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
337pub(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 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 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 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 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 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 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 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 let mut parser = Parser::default();
728 let maw = Block::parse(crate::Span::new("[NOTE]\n"), &mut parser);
729
730 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 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 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}