use crate::options::Extensions;
use crate::parser::utils::helpers::strip_newline;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AdmonitionMarker {
Admonition,
DetailsCollapsed,
DetailsExpanded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AdmonitionOpen {
pub marker: AdmonitionMarker,
pub indent_len: usize,
pub marker_len: usize,
pub type_range: Option<(usize, usize)>,
pub title_range: Option<(usize, usize)>,
}
fn is_class_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
pub(crate) fn try_parse_admonition_open(content: &str, ext: &Extensions) -> Option<AdmonitionOpen> {
if !ext.python_markdown_admonitions && !ext.pymdownx_details {
return None;
}
let (line, _newline) = strip_newline(content);
let indent_len = line.bytes().take_while(|&b| b == b' ').count();
if indent_len > 3 {
return None;
}
let rest = &line[indent_len..];
let rest_bytes = rest.as_bytes();
let (marker, marker_len) = if rest.starts_with("!!!") {
if !ext.python_markdown_admonitions || rest_bytes.get(3) == Some(&b'!') {
return None;
}
(AdmonitionMarker::Admonition, 3)
} else if rest.starts_with("???") {
if !ext.pymdownx_details || rest_bytes.get(3) == Some(&b'?') {
return None;
}
if rest_bytes.get(3) == Some(&b'+') {
(AdmonitionMarker::DetailsExpanded, 4)
} else {
(AdmonitionMarker::DetailsCollapsed, 3)
}
} else {
return None;
};
let after_marker_abs = indent_len + marker_len;
let after_marker = &line[after_marker_abs..];
let lead = after_marker.bytes().take_while(|&b| b == b' ').count();
let body_abs = after_marker_abs + lead;
let body = after_marker[lead..].trim_end_matches(' ');
let (type_str, type_abs, title_range) = if body.ends_with('"') && body.matches('"').count() >= 2
{
let first_q = body.find('"').unwrap();
if first_q > 0 && body.as_bytes()[first_q - 1] != b' ' {
return None;
}
let type_str = body[..first_q].trim_end();
let title_abs = body_abs + first_q;
(
type_str,
body_abs,
Some((title_abs, title_abs + body[first_q..].len())),
)
} else {
(body, body_abs, None)
};
if !type_str.chars().all(|c| is_class_char(c) || c == ' ') {
return None;
}
let type_range = if type_str.is_empty() {
None
} else {
Some((type_abs, type_abs + type_str.len()))
};
if marker == AdmonitionMarker::Admonition && type_range.is_none() {
return None;
}
Some(AdmonitionOpen {
marker,
indent_len,
marker_len,
type_range,
title_range,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn both() -> Extensions {
Extensions {
python_markdown_admonitions: true,
pymdownx_details: true,
..Extensions::default()
}
}
fn slice(content: &str, range: (usize, usize)) -> &str {
&content[range.0..range.1]
}
#[test]
fn basic_admonition() {
let c = "!!! note\n";
let a = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(a.marker, AdmonitionMarker::Admonition);
assert_eq!(a.indent_len, 0);
assert_eq!(a.marker_len, 3);
assert_eq!(slice(c, a.type_range.unwrap()), "note");
assert!(a.title_range.is_none());
}
#[test]
fn admonition_with_title() {
let c = "!!! note \"Heads up\"\n";
let a = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(slice(c, a.type_range.unwrap()), "note");
assert_eq!(slice(c, a.title_range.unwrap()), "\"Heads up\"");
}
#[test]
fn admonition_with_extra_classes() {
let c = "!!! danger highlight \"Don't\"\n";
let a = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(slice(c, a.type_range.unwrap()), "danger highlight");
assert_eq!(slice(c, a.title_range.unwrap()), "\"Don't\"");
}
#[test]
fn admonition_empty_title() {
let c = "!!! note \"\"\n";
let a = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(slice(c, a.type_range.unwrap()), "note");
assert_eq!(slice(c, a.title_range.unwrap()), "\"\"");
}
#[test]
fn details_collapsed_and_expanded() {
let collapsed = try_parse_admonition_open("??? note\n", &both()).unwrap();
assert_eq!(collapsed.marker, AdmonitionMarker::DetailsCollapsed);
assert_eq!(collapsed.marker_len, 3);
let c = "???+ note\n";
let expanded = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(expanded.marker, AdmonitionMarker::DetailsExpanded);
assert_eq!(expanded.marker_len, 4);
assert_eq!(slice(c, expanded.type_range.unwrap()), "note");
}
#[test]
fn details_allow_empty_type() {
let a = try_parse_admonition_open("???\n", &both()).unwrap();
assert_eq!(a.marker, AdmonitionMarker::DetailsCollapsed);
assert!(a.type_range.is_none());
}
#[test]
fn admonition_requires_type() {
assert!(try_parse_admonition_open("!!!\n", &both()).is_none());
assert!(try_parse_admonition_open("!!! \"only title\"\n", &both()).is_none());
}
#[test]
fn leading_indent_allowed_up_to_three() {
let c = " !!! note\n";
let a = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(a.indent_len, 3);
assert_eq!(slice(c, a.type_range.unwrap()), "note");
assert!(try_parse_admonition_open(" !!! note\n", &both()).is_none());
}
#[test]
fn rejects_non_class_content() {
assert!(try_parse_admonition_open("!!! warning, this is bad.\n", &both()).is_none());
assert!(try_parse_admonition_open("!!! note.\n", &both()).is_none());
assert!(try_parse_admonition_open("!!! note two three\n", &both()).is_some());
}
#[test]
fn four_bangs_is_not_a_marker() {
assert!(try_parse_admonition_open("!!!! note\n", &both()).is_none());
assert!(try_parse_admonition_open("???? note\n", &both()).is_none());
}
#[test]
fn gated_on_extension() {
let only_adm = Extensions {
python_markdown_admonitions: true,
..Extensions::default()
};
assert!(try_parse_admonition_open("!!! note\n", &only_adm).is_some());
assert!(try_parse_admonition_open("??? note\n", &only_adm).is_none());
let only_det = Extensions {
pymdownx_details: true,
..Extensions::default()
};
assert!(try_parse_admonition_open("??? note\n", &only_det).is_some());
assert!(try_parse_admonition_open("!!! note\n", &only_det).is_none());
let off = Extensions::default();
assert!(try_parse_admonition_open("!!! note\n", &off).is_none());
assert!(try_parse_admonition_open("??? note\n", &off).is_none());
}
#[test]
fn no_space_before_type_is_allowed() {
let c = "!!!note\n";
let a = try_parse_admonition_open(c, &both()).unwrap();
assert_eq!(slice(c, a.type_range.unwrap()), "note");
}
}