use std::slice::Iter;
use crate::{
HasSpan, Parser, Span,
attributes::Attrlist,
blocks::{
AdmonitionVariant, Block, CompoundDelimitedBlock, ContentModel, IsBlock, RawDelimitedBlock,
SimpleBlock, TableBlock, metadata::BlockMetadata,
},
content::Content,
document::InterpretedValue,
internal::debug::DebugSliceReference,
span::MatchedItem,
strings::CowStr,
warnings::MatchAndWarnings,
};
#[derive(Clone, Eq, PartialEq)]
pub struct AdmonitionBlock<'src> {
variant: AdmonitionVariant,
label: String,
icons_font: bool,
content_model: ContentModel,
content: Option<Content<'src>>,
blocks: Vec<Block<'src>>,
source: Span<'src>,
title_source: Option<Span<'src>>,
title: Option<String>,
anchor: Option<Span<'src>>,
anchor_reftext: Option<Span<'src>>,
attrlist: Option<Attrlist<'src>>,
}
impl<'src> AdmonitionBlock<'src> {
pub(crate) fn parse(
metadata: &BlockMetadata<'src>,
parser: &mut Parser,
) -> Option<MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>>> {
if let Some(style) = metadata.attrlist.as_ref().and_then(|a| a.block_style())
&& let Some(variant) = AdmonitionVariant::from_style(style)
{
return Some(Self::parse_masquerade(metadata, parser, variant));
}
if metadata
.attrlist
.as_ref()
.and_then(|a| a.block_style())
.is_none()
&& let Some((variant, content_start)) =
admonition_paragraph_prefix(metadata.block_start)
{
return Some(Self::parse_paragraph(
metadata,
parser,
variant,
content_start,
));
}
None
}
fn parse_masquerade(
metadata: &BlockMetadata<'src>,
parser: &mut Parser,
variant: AdmonitionVariant,
) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
let first_line = metadata.block_start.take_normalized_line().item;
if is_example_or_open_delimiter(&first_line)
&& let Some(MatchAndWarnings {
item: Some(inner),
warnings,
}) = CompoundDelimitedBlock::parse(metadata, parser)
{
let after = inner.after;
let blocks = inner.item.into_nested_blocks();
return Self::finish(
metadata,
parser,
variant,
ContentModel::Compound,
None,
blocks,
after,
warnings,
);
}
if RawDelimitedBlock::is_valid_delimiter(&first_line)
|| CompoundDelimitedBlock::is_valid_delimiter(&first_line)
|| TableBlock::is_table_delimiter(&first_line)
{
return MatchAndWarnings {
item: None,
warnings: vec![],
};
}
if let Some(inner) = SimpleBlock::parse(metadata, parser) {
return Self::finish(
metadata,
parser,
variant,
ContentModel::Simple,
Some(inner.item.content().clone()),
vec![],
inner.after,
vec![],
);
}
MatchAndWarnings {
item: None,
warnings: vec![],
}
}
fn parse_paragraph(
metadata: &BlockMetadata<'src>,
parser: &mut Parser,
variant: AdmonitionVariant,
content_start: Span<'src>,
) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
let inner_metadata = BlockMetadata {
title_source: None,
title: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
source: content_start,
block_start: content_start,
};
if let Some(inner) = SimpleBlock::parse(&inner_metadata, parser) {
return Self::finish(
metadata,
parser,
variant,
ContentModel::Simple,
Some(inner.item.content().clone()),
vec![],
inner.after,
vec![],
);
}
MatchAndWarnings {
item: None,
warnings: vec![],
}
}
#[allow(clippy::too_many_arguments)]
fn finish(
metadata: &BlockMetadata<'src>,
parser: &Parser,
variant: AdmonitionVariant,
content_model: ContentModel,
content: Option<Content<'src>>,
blocks: Vec<Block<'src>>,
after: Span<'src>,
warnings: Vec<crate::warnings::Warning<'src>>,
) -> MatchAndWarnings<'src, Option<MatchedItem<'src, Self>>> {
let source = metadata
.source
.trim_remainder(after)
.trim_trailing_whitespace();
MatchAndWarnings {
item: Some(MatchedItem {
item: Self {
variant,
label: resolve_caption(variant, parser),
icons_font: icons_are_font(parser),
content_model,
content,
blocks,
source,
title_source: metadata.title_source,
title: metadata.title.clone(),
anchor: metadata.anchor,
anchor_reftext: metadata.anchor_reftext,
attrlist: metadata.attrlist.clone(),
},
after,
}),
warnings,
}
}
pub fn variant(&self) -> AdmonitionVariant {
self.variant
}
pub fn name(&self) -> &'static str {
self.variant.name()
}
pub fn label(&self) -> &str {
&self.label
}
pub fn icons_font(&self) -> bool {
self.icons_font
}
pub fn content(&self) -> Option<&Content<'src>> {
self.content.as_ref()
}
}
fn is_example_or_open_delimiter(line: &Span<'_>) -> bool {
let data = line.data();
if data == "--" {
return true;
}
data.len() >= 4 && data.chars().all(|c| c == '=')
}
fn resolve_caption(variant: AdmonitionVariant, parser: &Parser) -> String {
let key = format!("{}-caption", variant.name());
match parser.attribute_value(key) {
InterpretedValue::Value(value) => value,
_ => variant.default_caption().to_string(),
}
}
fn icons_are_font(parser: &Parser) -> bool {
matches!(parser.attribute_value("icons"), InterpretedValue::Value(value) if value == "font")
}
pub(crate) fn admonition_paragraph_prefix(
block_start: Span<'_>,
) -> Option<(AdmonitionVariant, Span<'_>)> {
let line = block_start.take_normalized_line().item;
let data = line.data();
for variant in [
AdmonitionVariant::Note,
AdmonitionVariant::Tip,
AdmonitionVariant::Important,
AdmonitionVariant::Caution,
AdmonitionVariant::Warning,
] {
if let Some(rest) = data.strip_prefix(variant.style())
&& let Some(rest) = rest.strip_prefix(':')
&& (rest.starts_with(' ') || rest.starts_with('\t'))
{
let whitespace_len = rest.len() - rest.trim_start_matches([' ', '\t']).len();
let prefix_len = variant.style().len() + 1 + whitespace_len;
return Some((variant, block_start.discard(prefix_len)));
}
}
None
}
pub(crate) fn starts_with_admonition_label(line: Span<'_>) -> bool {
admonition_paragraph_prefix(line).is_some()
}
impl<'src> IsBlock<'src> for AdmonitionBlock<'src> {
fn content_model(&self) -> ContentModel {
self.content_model
}
fn raw_context(&self) -> CowStr<'src> {
"admonition".into()
}
fn declared_style(&'src self) -> Option<&'src str> {
Some(self.variant.style())
}
fn rendered_content(&'src self) -> Option<&'src str> {
self.content.as_ref().map(|content| content.rendered())
}
fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
self.blocks.iter()
}
fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
&mut self.blocks
}
fn content_mut(&mut self) -> Option<&mut Content<'src>> {
self.content.as_mut()
}
fn title_source(&'src self) -> Option<Span<'src>> {
self.title_source
}
fn title(&self) -> Option<&str> {
self.title.as_deref()
}
fn anchor(&'src self) -> Option<Span<'src>> {
self.anchor
}
fn anchor_reftext(&'src self) -> Option<Span<'src>> {
self.anchor_reftext
}
fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
self.attrlist.as_ref()
}
}
impl<'src> HasSpan<'src> for AdmonitionBlock<'src> {
fn span(&self) -> Span<'src> {
self.source
}
}
impl std::fmt::Debug for AdmonitionBlock<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AdmonitionBlock")
.field("variant", &self.variant)
.field("label", &self.label)
.field("icons_font", &self.icons_font)
.field("content_model", &self.content_model)
.field("content", &self.content)
.field("blocks", &DebugSliceReference(&self.blocks))
.field("source", &self.source)
.field("title_source", &self.title_source)
.field("title", &self.title)
.field("anchor", &self.anchor)
.field("anchor_reftext", &self.anchor_reftext)
.field("attrlist", &self.attrlist)
.finish()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::panic)]
use std::ops::Deref;
use crate::{
blocks::{AdmonitionVariant, Block, ContentModel, IsBlock},
tests::prelude::*,
};
fn parse_one(input: &'static str) -> Block<'static> {
let mut parser = Parser::default();
Block::parse(crate::Span::new(input), &mut parser)
.unwrap_if_no_warnings()
.unwrap()
.item
}
fn as_admonition<'a>(block: &'a Block<'a>) -> &'a crate::blocks::AdmonitionBlock<'a> {
match block {
Block::Admonition(admonition) => admonition,
other => panic!("expected an admonition block, got {other:?}"),
}
}
mod admonition_variant {
use crate::blocks::AdmonitionVariant;
#[test]
fn style() {
assert_eq!(AdmonitionVariant::Note.style(), "NOTE");
assert_eq!(AdmonitionVariant::Tip.style(), "TIP");
assert_eq!(AdmonitionVariant::Important.style(), "IMPORTANT");
assert_eq!(AdmonitionVariant::Caution.style(), "CAUTION");
assert_eq!(AdmonitionVariant::Warning.style(), "WARNING");
}
#[test]
fn name() {
assert_eq!(AdmonitionVariant::Note.name(), "note");
assert_eq!(AdmonitionVariant::Tip.name(), "tip");
assert_eq!(AdmonitionVariant::Important.name(), "important");
assert_eq!(AdmonitionVariant::Caution.name(), "caution");
assert_eq!(AdmonitionVariant::Warning.name(), "warning");
}
#[test]
fn default_caption() {
assert_eq!(AdmonitionVariant::Note.default_caption(), "Note");
assert_eq!(AdmonitionVariant::Tip.default_caption(), "Tip");
assert_eq!(AdmonitionVariant::Important.default_caption(), "Important");
assert_eq!(AdmonitionVariant::Caution.default_caption(), "Caution");
assert_eq!(AdmonitionVariant::Warning.default_caption(), "Warning");
}
#[test]
fn from_style() {
assert_eq!(
AdmonitionVariant::from_style("NOTE"),
Some(AdmonitionVariant::Note)
);
assert_eq!(
AdmonitionVariant::from_style("TIP"),
Some(AdmonitionVariant::Tip)
);
assert_eq!(
AdmonitionVariant::from_style("IMPORTANT"),
Some(AdmonitionVariant::Important)
);
assert_eq!(
AdmonitionVariant::from_style("CAUTION"),
Some(AdmonitionVariant::Caution)
);
assert_eq!(
AdmonitionVariant::from_style("WARNING"),
Some(AdmonitionVariant::Warning)
);
assert_eq!(AdmonitionVariant::from_style("note"), None);
assert_eq!(AdmonitionVariant::from_style("Note"), None);
assert_eq!(AdmonitionVariant::from_style("example"), None);
}
#[test]
fn impl_debug() {
assert_eq!(
format!("{:?}", AdmonitionVariant::Note),
"AdmonitionVariant::Note"
);
assert_eq!(
format!("{:?}", AdmonitionVariant::Tip),
"AdmonitionVariant::Tip"
);
assert_eq!(
format!("{:?}", AdmonitionVariant::Important),
"AdmonitionVariant::Important"
);
assert_eq!(
format!("{:?}", AdmonitionVariant::Caution),
"AdmonitionVariant::Caution"
);
assert_eq!(
format!("{:?}", AdmonitionVariant::Warning),
"AdmonitionVariant::Warning"
);
}
#[test]
fn impl_clone() {
let v1 = AdmonitionVariant::Note;
let v2 = v1;
assert_eq!(v1, v2);
}
}
#[test]
fn paragraph_form() {
let block = parse_one("NOTE: This is a note.");
let admonition = as_admonition(&block);
assert_eq!(admonition.variant(), AdmonitionVariant::Note);
assert_eq!(admonition.name(), "note");
assert_eq!(admonition.label(), "Note");
assert!(!admonition.icons_font());
assert_eq!(admonition.content_model(), ContentModel::Simple);
assert_eq!(admonition.content().unwrap().rendered(), "This is a note.");
assert_eq!(admonition.rendered_content(), Some("This is a note."));
assert_eq!(admonition.raw_context().deref(), "admonition");
assert_eq!(admonition.resolved_context().deref(), "admonition");
assert_eq!(admonition.declared_style(), Some("NOTE"));
assert!(admonition.nested_blocks().next().is_none());
assert!(admonition.title().is_none());
assert!(admonition.attrlist().is_none());
}
#[test]
fn paragraph_form_all_variants() {
for (input, variant, name) in [
("NOTE: x", AdmonitionVariant::Note, "note"),
("TIP: x", AdmonitionVariant::Tip, "tip"),
("IMPORTANT: x", AdmonitionVariant::Important, "important"),
("CAUTION: x", AdmonitionVariant::Caution, "caution"),
("WARNING: x", AdmonitionVariant::Warning, "warning"),
] {
let block = parse_one(input);
let admonition = as_admonition(&block);
assert_eq!(admonition.variant(), variant);
assert_eq!(admonition.name(), name);
}
}
#[test]
fn paragraph_form_multiline() {
let block = parse_one("NOTE: first line\nsecond line");
let admonition = as_admonition(&block);
assert_eq!(
admonition.content().unwrap().rendered(),
"first line\nsecond line"
);
}
#[test]
fn paragraph_form_tab_separator() {
let block = parse_one("NOTE:\tindented with a tab");
let admonition = as_admonition(&block);
assert_eq!(admonition.variant(), AdmonitionVariant::Note);
assert_eq!(
admonition.content().unwrap().rendered(),
"indented with a tab"
);
}
#[test]
fn not_an_admonition_when_lowercase_label() {
let block = parse_one("note: not an admonition");
assert_eq!(block.raw_context().deref(), "paragraph");
}
#[test]
fn not_an_admonition_without_separating_space() {
let block = parse_one("NOTE:no space");
assert_eq!(block.raw_context().deref(), "paragraph");
}
#[test]
fn not_an_admonition_for_partial_label() {
let block = parse_one("NOTES: a longer word");
assert_eq!(block.raw_context().deref(), "paragraph");
}
#[test]
fn indented_label_is_a_literal_block_not_an_admonition() {
let block = parse_one(" NOTE: indented text");
assert_eq!(block.raw_context().deref(), "paragraph");
}
#[test]
fn masquerade_on_example_block_is_compound() {
let block = parse_one("[NOTE]\n====\nCompound content.\n====");
let admonition = as_admonition(&block);
assert_eq!(admonition.variant(), AdmonitionVariant::Note);
assert_eq!(admonition.content_model(), ContentModel::Compound);
assert!(admonition.content().is_none());
assert!(admonition.rendered_content().is_none());
assert_eq!(admonition.nested_blocks().count(), 1);
}
#[test]
fn masquerade_propagates_unterminated_warning() {
let mut parser = Parser::default();
let maw = Block::parse(crate::Span::new("[NOTE]\n====\nunclosed"), &mut parser);
let block = maw.item.unwrap().item;
assert_eq!(block.raw_context().deref(), "admonition");
assert_eq!(maw.warnings.len(), 1);
assert_eq!(
maw.warnings.first().unwrap().warning,
crate::warnings::WarningType::UnterminatedDelimitedBlock
);
}
#[test]
fn masquerade_on_open_block_is_compound() {
let block = parse_one("[WARNING]\n--\npara one\n\npara two\n--");
let admonition = as_admonition(&block);
assert_eq!(admonition.variant(), AdmonitionVariant::Warning);
assert_eq!(admonition.content_model(), ContentModel::Compound);
assert_eq!(admonition.nested_blocks().count(), 2);
}
#[test]
fn masquerade_on_paragraph_is_simple() {
let block = parse_one("[TIP]\nA single paragraph.");
let admonition = as_admonition(&block);
assert_eq!(admonition.variant(), AdmonitionVariant::Tip);
assert_eq!(admonition.content_model(), ContentModel::Simple);
assert_eq!(
admonition.content().unwrap().rendered(),
"A single paragraph."
);
}
#[test]
fn masquerade_with_title() {
let block = parse_one("[NOTE]\n.A title\n====\nContent.\n====");
let admonition = as_admonition(&block);
assert_eq!(admonition.title(), Some("A title"));
}
#[test]
fn masquerade_does_not_apply_to_other_containers() {
assert_eq!(
parse_one("[NOTE]\n****\nx\n****").raw_context().deref(),
"sidebar"
);
assert_eq!(
parse_one("[NOTE]\n____\nx\n____").raw_context().deref(),
"quote"
);
assert_eq!(
parse_one("[NOTE]\n----\nx\n----").raw_context().deref(),
"listing"
);
assert_eq!(
parse_one("[NOTE]\n....\nx\n....").raw_context().deref(),
"literal"
);
assert_eq!(
parse_one("[NOTE]\n++++\nx\n++++").raw_context().deref(),
"pass"
);
}
#[test]
fn impl_debug() {
let block = parse_one("NOTE: hi");
let admonition = as_admonition(&block);
let debug = format!("{admonition:?}");
assert!(debug.starts_with("AdmonitionBlock {"));
assert!(debug.contains("variant: AdmonitionVariant::Note"));
}
#[test]
fn impl_clone() {
let block = parse_one("NOTE: clone me");
let admonition = as_admonition(&block).clone();
assert_eq!(admonition.variant(), AdmonitionVariant::Note);
}
#[test]
fn masquerade_without_content_is_not_an_admonition() {
let mut parser = Parser::default();
let maw = Block::parse(crate::Span::new("[NOTE]\n"), &mut parser);
let block = maw.item.unwrap().item;
assert_eq!(block.raw_context().deref(), "paragraph");
assert_eq!(maw.warnings.len(), 1);
assert_eq!(
maw.warnings.first().unwrap().warning,
crate::warnings::WarningType::MissingBlockAfterTitleOrAttributeList
);
}
#[test]
fn block_enum_delegates_to_admonition() {
let simple = parse_one("NOTE: text");
assert_eq!(simple.content_model(), ContentModel::Simple);
assert_eq!(simple.rendered_content(), Some("text"));
assert_eq!(simple.raw_context().deref(), "admonition");
assert_eq!(simple.declared_style(), Some("NOTE"));
assert!(simple.title_source().is_none());
assert!(simple.anchor_reftext().is_none());
assert_eq!(simple.substitution_group(), SubstitutionGroup::Normal);
assert!(simple.nested_blocks().next().is_none());
assert!(format!("{simple:?}").starts_with("Block::Admonition"));
let compound = parse_one("[NOTE]\n====\nx\n====");
assert_eq!(compound.content_model(), ContentModel::Compound);
assert_eq!(compound.nested_blocks().count(), 1);
}
#[test]
fn block_declared_style_for_non_admonition_kinds() {
let media = parse_one("image::a.png[]");
assert_eq!(media.raw_context().deref(), "image");
assert!(media.declared_style().is_none());
let list = parse_one("* item");
let item = list.nested_blocks().next().unwrap();
assert_eq!(item.raw_context().deref(), "list_item");
assert!(item.declared_style().is_none());
let attribute = parse_one(":foo: bar");
assert_eq!(attribute.raw_context().deref(), "attribute");
assert!(attribute.declared_style().is_none());
}
mod caption_and_icons {
use crate::tests::prelude::*;
#[test]
fn default_caption_when_icons_unset() {
let doc = Parser::default().parse("CAUTION: Slippery when wet.");
assert_xpath(
&doc,
"//td[@class=\"icon\"]/div[@class=\"title\"][text()=\"Caution\"]",
1,
);
}
#[test]
fn caption_override_via_attribute() {
let doc = Parser::default().parse(":note-caption: Remarque\n\nNOTE: Bonjour.");
assert_xpath(
&doc,
"//td[@class=\"icon\"]/div[@class=\"title\"][text()=\"Remarque\"]",
1,
);
}
#[test]
fn font_icons_when_enabled() {
let doc = Parser::default().parse(":icons: font\n\nNOTE: This is a note.");
assert_css(&doc, "td.icon i.fa.icon-note", 1);
assert_css(&doc, "td.icon .title", 0);
}
}
}